From c6b8b9e466f9af3d14735cda7d7d57ea5486d71d Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Fri, 14 Nov 2025 17:03:35 +0100 Subject: [PATCH 01/92] Add LSP forwarding --- crates/common/src/api_types.rs | 53 +++++ .../orchestrator/src/container/http_client.rs | 16 ++ crates/orchestrator/src/handlers/lsp.rs | 207 ++++++++++++++++++ crates/orchestrator/src/handlers/mod.rs | 3 +- crates/orchestrator/src/lib.rs | 13 +- crates/wrapper/src/handlers/lsp.rs | 33 +++ crates/wrapper/src/handlers/mod.rs | 1 + crates/wrapper/src/main.rs | 1 + crates/wrapper/src/manager.rs | 38 ++++ 9 files changed, 361 insertions(+), 4 deletions(-) create mode 100644 crates/orchestrator/src/handlers/lsp.rs create mode 100644 crates/wrapper/src/handlers/lsp.rs diff --git a/crates/common/src/api_types.rs b/crates/common/src/api_types.rs index e5ea87fc..76694961 100644 --- a/crates/common/src/api_types.rs +++ b/crates/common/src/api_types.rs @@ -566,6 +566,59 @@ pub struct ReadSourceCodeRequest { pub range: Option, } +/// JSON-RPC request +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, IntoParams)] +pub struct JsonRpcRequest { + /// The JSON-RPC version (always "2.0") + #[schema(example = "2.0")] + pub jsonrpc: String, + + /// Optional request ID (for requests that expect a response) + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + + /// The method name + #[schema(example = "textDocument/hover")] + pub method: String, + + /// The method parameters (structure varies by method) + #[serde(skip_serializing_if = "Option::is_none")] + pub params: Option, +} + +/// JSON-RPC response +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct JsonRpcResponse { + /// The JSON-RPC version (always "2.0") + #[schema(example = "2.0")] + pub jsonrpc: String, + + /// The request ID (matches the request) + pub id: Value, + + /// The result (present on success) + #[serde(skip_serializing_if = "Option::is_none")] + pub result: Option, + + /// The error (present on failure) + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +/// JSON-RPC error +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct JsonRpcError { + /// Error code + pub code: i32, + + /// Error message + pub message: String, + + /// Optional additional error data + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option, +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/orchestrator/src/container/http_client.rs b/crates/orchestrator/src/container/http_client.rs index 0f0de0f0..42108d66 100644 --- a/crates/orchestrator/src/container/http_client.rs +++ b/crates/orchestrator/src/container/http_client.rs @@ -98,4 +98,20 @@ impl ContainerHttpClient { // Response is directly Vec Ok(response.json().await?) } + + /// Forward a raw LSP JSON-RPC request to the container + pub async fn forward_lsp_request( + &self, + request: &serde_json::Value, + ) -> Result> { + let url = format!("{}/lsp", self.base_url); + let response = self.client.post(&url).json(request).send().await?; + + if !response.status().is_success() { + let error_text = response.text().await?; + return Err(format!("LSP request failed: {}", error_text).into()); + } + + Ok(response.json().await?) + } } diff --git a/crates/orchestrator/src/handlers/lsp.rs b/crates/orchestrator/src/handlers/lsp.rs new file mode 100644 index 00000000..1d54887b --- /dev/null +++ b/crates/orchestrator/src/handlers/lsp.rs @@ -0,0 +1,207 @@ +use crate::handlers::container_proxy; +use crate::AppState; +use actix_web::web::{Data, Json}; +use actix_web::HttpResponse; +use log::{error, info, warn}; +use lsproxy_common::api_types::{JsonRpcRequest, JsonRpcResponse}; +use serde_json::{json, Value}; + +/// Forward LSP JSON-RPC requests to language servers +#[utoipa::path( + post, + path = "/lsp", + tag = "lsp", + request_body = JsonRpcRequest, + responses( + (status = 200, description = "LSP request processed successfully", body = JsonRpcResponse), + (status = 400, description = "Bad request - invalid JSON-RPC format"), + (status = 500, description = "Internal server error") + ) +)] +pub async fn lsp(data: Data, request: Json) -> HttpResponse { + let lsp_req = request.into_inner(); + + // Convert LspRequest to Value for internal processing + let req_value = serde_json::to_value(&lsp_req).unwrap_or_else(|_| json!({})); + + let method = &lsp_req.method; + info!("Received LSP request: method={}", method); + + // Handle lifecycle requests locally + if is_lifecycle_method(method) { + return handle_lifecycle_request(&req_value, method); + } + + // For language feature requests, extract document URI and route to appropriate backend + let document_uri = match extract_document_uri(&req_value) { + Some(uri) => uri, + None => { + warn!( + "Could not extract document URI from request for method: {}", + method + ); + return HttpResponse::BadRequest().json(create_error_response( + req_value.get("id").and_then(|id| id.as_u64()), + -32602, + "Invalid params: could not extract document URI", + )); + } + }; + + // Convert file:// URI to path + let file_path = match uri_to_path(&document_uri) { + Some(path) => path, + None => { + error!("Invalid document URI: {}", document_uri); + return HttpResponse::BadRequest().json(create_error_response( + req_value.get("id").and_then(|id| id.as_u64()), + -32602, + "Invalid params: invalid document URI", + )); + } + }; + + // Get container client for this file's language + let client = match container_proxy::get_client_for_file(&data.orchestrator, &file_path).await { + Ok(client) => client, + Err(e) => { + error!("Failed to get container client: {}", e); + return HttpResponse::InternalServerError().json(create_error_response( + req_value.get("id").and_then(|id| id.as_u64()), + -32603, + &format!("Internal error: {}", e), + )); + } + }; + + // Forward request to container + match client.forward_lsp_request(&req_value).await { + Ok(response) => HttpResponse::Ok().json(response), + Err(e) => { + error!("Container request failed: {}", e); + HttpResponse::InternalServerError().json(create_error_response( + req_value.get("id").and_then(|id| id.as_u64()), + -32603, + &format!("Internal error: {}", e), + )) + } + } +} + +/// Check if a method is a lifecycle method that should be handled by the orchestrator +fn is_lifecycle_method(method: &str) -> bool { + matches!( + method, + "initialize" | "initialized" | "shutdown" | "exit" | "$/cancelRequest" | "$/setTrace" + ) +} + +/// Handle lifecycle requests locally +fn handle_lifecycle_request(request: &Value, method: &str) -> HttpResponse { + let id = request.get("id").and_then(|id| id.as_u64()); + + match method { + "initialize" => { + // Return capabilities advertised by the orchestrator + let response = json!({ + "jsonrpc": "2.0", + "id": id, + "result": { + "capabilities": { + "hoverProvider": true, + "declarationProvider": true, + "definitionProvider": true, + "typeDefinitionProvider": true, + "referencesProvider": true, + "documentSymbolProvider": true, + "typeHierarchyProvider": true, + }, + "serverInfo": { + "name": "nuanced-lsp", + "version": env!("CARGO_PKG_VERSION") + } + } + }); + HttpResponse::Ok().json(response) + } + "initialized" => { + // Notification - no response needed + HttpResponse::Ok().finish() + } + "shutdown" => { + // Acknowledge shutdown + let response = json!({ + "jsonrpc": "2.0", + "id": id, + "result": null + }); + HttpResponse::Ok().json(response) + } + "exit" => { + // Notification - no response needed + HttpResponse::Ok().finish() + } + "$/cancelRequest" | "$/setTrace" => { + // These are notifications or special requests - acknowledge + HttpResponse::Ok().finish() + } + _ => { + // Unknown lifecycle method + HttpResponse::BadRequest().json(create_error_response(id, -32601, "Method not found")) + } + } +} + +/// Extract document URI from JSON-RPC request parameters +fn extract_document_uri(request: &Value) -> Option { + // Try textDocument.uri + if let Some(uri) = request + .get("params") + .and_then(|p| p.get("textDocument")) + .and_then(|td| td.get("uri")) + .and_then(|u| u.as_str()) + { + return Some(uri.to_string()); + } + + // Try uri directly + if let Some(uri) = request + .get("params") + .and_then(|p| p.get("uri")) + .and_then(|u| u.as_str()) + { + return Some(uri.to_string()); + } + + None +} + +/// Convert file:// URI to file path +fn uri_to_path(uri: &str) -> Option { + if !uri.starts_with("file://") { + return None; + } + + // Remove file:// prefix + let path = &uri[7..]; + + // Handle Windows-style paths (file:///C:/...) + if path.starts_with('/') && path.len() > 2 && path.chars().nth(2) == Some(':') { + return Some(path[1..].to_string()); + } + + // Unix-style paths + Some(path.to_string()) +} + +/// Create a JSON-RPC error response +fn create_error_response(id: Option, code: i32, message: &str) -> Value { + json!({ + "jsonrpc": "2.0", + "id": id, + "error": { + "code": code, + "message": message + } + }) +} diff --git a/crates/orchestrator/src/handlers/mod.rs b/crates/orchestrator/src/handlers/mod.rs index 217c42ca..eb255cdc 100644 --- a/crates/orchestrator/src/handlers/mod.rs +++ b/crates/orchestrator/src/handlers/mod.rs @@ -5,10 +5,11 @@ mod find_referenced_symbols; mod find_references; mod health; mod list_files; +mod lsp; mod read_source_code; pub(crate) mod container_proxy; pub use self::{ definitions_in_file::*, find_definition::*, find_identifier::*, find_referenced_symbols::*, - find_references::*, health::*, list_files::*, read_source_code::*, + find_references::*, health::*, list_files::*, lsp::*, read_source_code::*, }; diff --git a/crates/orchestrator/src/lib.rs b/crates/orchestrator/src/lib.rs index 2a2182d2..5454682d 100644 --- a/crates/orchestrator/src/lib.rs +++ b/crates/orchestrator/src/lib.rs @@ -23,13 +23,14 @@ mod handlers; use crate::handlers::{ definitions_in_file, find_definition, find_referenced_symbols, find_references, health_check, - list_files, + list_files, lsp, }; use lsproxy_common::api_types::{ get_mount_dir, set_global_mount_dir, CodeContext, DefinitionResponse, ErrorResponse, FilePosition, FileRange, FileSymbolsRequest, GetDefinitionRequest, GetReferencedSymbolsRequest, - GetReferencesRequest, HealthResponse, Position, ReferenceWithSymbolDefinitions, - ReferencedSymbolsResponse, ReferencesResponse, SupportedLanguages, Symbol, SymbolResponse, + GetReferencesRequest, HealthResponse, JsonRpcError, JsonRpcRequest, JsonRpcResponse, Position, + ReferenceWithSymbolDefinitions, ReferencedSymbolsResponse, ReferencesResponse, + SupportedLanguages, Symbol, SymbolResponse, }; // use lsproxy_common::utils::doc_utils::make_code_sample; @@ -72,6 +73,9 @@ pub fn check_mount_dir() -> std::io::Result<()> { HealthResponse, FindIdentifierRequest, IdentifierResponse, + JsonRpcRequest, + JsonRpcResponse, + JsonRpcError, ) ), paths( @@ -83,6 +87,7 @@ pub fn check_mount_dir() -> std::io::Result<()> { crate::handlers::read_source_code, crate::handlers::find_referenced_symbols, crate::handlers::find_identifier, + crate::handlers::lsp, ), tags( (name = "lsproxy-api", description = "LSP Proxy API") @@ -274,6 +279,8 @@ pub async fn run_server_with_port_and_host( api_scope.service(resource(path).route(post().to(read_source_code))), ("/system/health", Some(Method::Get)) => api_scope.service(resource(path).route(get().to(health_check))), + ("/lsp", Some(Method::Post)) => + api_scope.service(resource(path).route(post().to(lsp))), (p, m) => panic!( "Invalid path configuration for {}: {:?}. Ensure the OpenAPI spec matches your handlers.", p, diff --git a/crates/wrapper/src/handlers/lsp.rs b/crates/wrapper/src/handlers/lsp.rs new file mode 100644 index 00000000..63548a75 --- /dev/null +++ b/crates/wrapper/src/handlers/lsp.rs @@ -0,0 +1,33 @@ +use crate::AppState; +use actix_web::{web, HttpResponse}; +use log::{debug, error}; +use lsproxy_common::api_types::JsonRpcRequest; + +/// Forward raw LSP JSON-RPC requests to the LSP server +/// +/// This endpoint provides direct access to the LSP server by forwarding +/// JSON-RPC requests and returning responses with minimal processing. +pub async fn lsp( + app_state: web::Data, + request: web::Json, +) -> HttpResponse { + let lsp_req = request.into_inner(); + let req_value = serde_json::to_value(&lsp_req).unwrap_or_else(|_| serde_json::json!({})); + + debug!("Forwarding LSP request: {:?}", req_value); + + // Forward the request to the LSP server + match app_state.manager.forward_lsp_request(req_value).await { + Ok(response) => HttpResponse::Ok().json(response), + Err(e) => { + error!("LSP request failed: {}", e); + HttpResponse::InternalServerError().json(serde_json::json!({ + "jsonrpc": "2.0", + "error": { + "code": -32603, + "message": format!("Internal error: {}", e) + } + })) + } + } +} diff --git a/crates/wrapper/src/handlers/mod.rs b/crates/wrapper/src/handlers/mod.rs index 34508eb5..0386267c 100644 --- a/crates/wrapper/src/handlers/mod.rs +++ b/crates/wrapper/src/handlers/mod.rs @@ -4,5 +4,6 @@ pub mod find_definition; pub mod find_identifier; pub mod find_referenced_symbols; pub mod find_references; +pub mod lsp; pub mod utils; diff --git a/crates/wrapper/src/main.rs b/crates/wrapper/src/main.rs index 31a198f7..4fef68de 100644 --- a/crates/wrapper/src/main.rs +++ b/crates/wrapper/src/main.rs @@ -264,6 +264,7 @@ async fn main() -> std::io::Result<()> { "/symbol/definitions-in-file", web::get().to(handlers::definitions_in_file::definitions_in_file), ) + .route("/lsp", web::post().to(handlers::lsp::lsp)) }) .bind(("0.0.0.0", args.port))? .run() diff --git a/crates/wrapper/src/manager.rs b/crates/wrapper/src/manager.rs index 0f5a14ac..4e287985 100644 --- a/crates/wrapper/src/manager.rs +++ b/crates/wrapper/src/manager.rs @@ -281,6 +281,44 @@ impl Manager { pub fn get_client(&self, _lang: lsproxy_common::api_types::SupportedLanguages) -> Option<()> { Some(()) } + + /// Forward a raw LSP JSON-RPC request to the LSP server + /// + /// This provides lightweight pass-through of JSON-RPC requests with minimal processing. + pub async fn forward_lsp_request( + &self, + request: serde_json::Value, + ) -> Result { + // Parse method from request + let method = request + .get("method") + .and_then(|m| m.as_str()) + .ok_or_else(|| { + LspManagerError::InternalError("Missing method in LSP request".to_string()) + })?; + + // Get params from request + let params = request.get("params").cloned(); + + // Forward to LSP server + let mut locked_client = self.client.lock().await; + let result = locked_client + .send_request(method, params) + .await + .map_err(|e| { + error!("Failed to forward LSP request: {}", e); + LspManagerError::InternalError(format!("LSP request failed: {}", e)) + })?; + + // Build response + let response = serde_json::json!({ + "jsonrpc": "2.0", + "id": request.get("id"), + "result": result + }); + + Ok(response) + } } // Convert from common LspError to wrapper-specific LspManagerError From 37c11f1010e89f1d7c671ae459ead0ff1921ac67 Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Mon, 17 Nov 2025 15:57:47 +0100 Subject: [PATCH 02/92] Use JsonRpc types instead of json literals --- crates/common/src/api_types.rs | 45 ++++++++- .../orchestrator/src/container/http_client.rs | 4 +- crates/orchestrator/src/handlers/lsp.rs | 91 ++++++++----------- crates/wrapper/src/handlers/lsp.rs | 32 ++++--- crates/wrapper/src/manager.rs | 28 ++---- 5 files changed, 110 insertions(+), 90 deletions(-) diff --git a/crates/common/src/api_types.rs b/crates/common/src/api_types.rs index 76694961..8122ee5f 100644 --- a/crates/common/src/api_types.rs +++ b/crates/common/src/api_types.rs @@ -566,7 +566,7 @@ pub struct ReadSourceCodeRequest { pub range: Option, } -/// JSON-RPC request +/// JSON-RPC request or notification #[derive(Debug, Clone, Serialize, Deserialize, ToSchema, IntoParams)] pub struct JsonRpcRequest { /// The JSON-RPC version (always "2.0") @@ -586,6 +586,21 @@ pub struct JsonRpcRequest { pub params: Option, } +impl JsonRpcRequest { + pub fn new( + id: Option, + method: String, + params: Option

, + ) -> Self { + Self { + jsonrpc: "2.0".to_string(), + id: id.map(|id| serde_json::to_value(id).unwrap()), + method, + params: params.map(|p| serde_json::to_value(p).unwrap()), + } + } +} + /// JSON-RPC response #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct JsonRpcResponse { @@ -605,6 +620,34 @@ pub struct JsonRpcResponse { pub error: Option, } +impl JsonRpcResponse { + pub fn new_result(id: Option, result: R) -> Self { + Self { + jsonrpc: "2.0".to_string(), + id: id + .map(|id| serde_json::to_value(id).unwrap()) + .unwrap_or(Value::Null), + result: Some(serde_json::to_value(result).unwrap()), + error: None, + } + } + + pub fn new_error(id: Option, code: i32, message: impl ToString) -> Self { + Self { + jsonrpc: "2.0".to_string(), + id: id + .map(|id| serde_json::to_value(id).unwrap()) + .unwrap_or(Value::Null), + result: None, + error: Some(JsonRpcError { + code, + message: message.to_string(), + data: None, + }), + } + } +} + /// JSON-RPC error #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct JsonRpcError { diff --git a/crates/orchestrator/src/container/http_client.rs b/crates/orchestrator/src/container/http_client.rs index 42108d66..4b493f9e 100644 --- a/crates/orchestrator/src/container/http_client.rs +++ b/crates/orchestrator/src/container/http_client.rs @@ -102,8 +102,8 @@ impl ContainerHttpClient { /// Forward a raw LSP JSON-RPC request to the container pub async fn forward_lsp_request( &self, - request: &serde_json::Value, - ) -> Result> { + request: &JsonRpcRequest, + ) -> Result> { let url = format!("{}/lsp", self.base_url); let response = self.client.post(&url).json(request).send().await?; diff --git a/crates/orchestrator/src/handlers/lsp.rs b/crates/orchestrator/src/handlers/lsp.rs index 1d54887b..804c64ef 100644 --- a/crates/orchestrator/src/handlers/lsp.rs +++ b/crates/orchestrator/src/handlers/lsp.rs @@ -21,27 +21,25 @@ use serde_json::{json, Value}; pub async fn lsp(data: Data, request: Json) -> HttpResponse { let lsp_req = request.into_inner(); - // Convert LspRequest to Value for internal processing - let req_value = serde_json::to_value(&lsp_req).unwrap_or_else(|_| json!({})); - let method = &lsp_req.method; - info!("Received LSP request: method={}", method); + let req_id = lsp_req.id.clone(); + info!("Received LSP request: id={:?} method={}", &req_id, method); // Handle lifecycle requests locally if is_lifecycle_method(method) { - return handle_lifecycle_request(&req_value, method); + return handle_lifecycle_request(&lsp_req, method); } // For language feature requests, extract document URI and route to appropriate backend - let document_uri = match extract_document_uri(&req_value) { + let document_uri = match extract_document_uri(lsp_req.params.as_ref()) { Some(uri) => uri, None => { warn!( "Could not extract document URI from request for method: {}", method ); - return HttpResponse::BadRequest().json(create_error_response( - req_value.get("id").and_then(|id| id.as_u64()), + return HttpResponse::BadRequest().json(JsonRpcResponse::new_error( + req_id, -32602, "Invalid params: could not extract document URI", )); @@ -53,8 +51,8 @@ pub async fn lsp(data: Data, request: Json) -> HttpRes Some(path) => path, None => { error!("Invalid document URI: {}", document_uri); - return HttpResponse::BadRequest().json(create_error_response( - req_value.get("id").and_then(|id| id.as_u64()), + return HttpResponse::BadRequest().json(JsonRpcResponse::new_error( + req_id, -32602, "Invalid params: invalid document URI", )); @@ -66,8 +64,8 @@ pub async fn lsp(data: Data, request: Json) -> HttpRes Ok(client) => client, Err(e) => { error!("Failed to get container client: {}", e); - return HttpResponse::InternalServerError().json(create_error_response( - req_value.get("id").and_then(|id| id.as_u64()), + return HttpResponse::InternalServerError().json(JsonRpcResponse::new_error( + req_id, -32603, &format!("Internal error: {}", e), )); @@ -75,12 +73,18 @@ pub async fn lsp(data: Data, request: Json) -> HttpRes }; // Forward request to container - match client.forward_lsp_request(&req_value).await { - Ok(response) => HttpResponse::Ok().json(response), + match client.forward_lsp_request(&lsp_req).await { + Ok(response) => { + info!( + "Received container response: id={} method={}", + response.id, method + ); + HttpResponse::Ok().json(response) + } Err(e) => { error!("Container request failed: {}", e); - HttpResponse::InternalServerError().json(create_error_response( - req_value.get("id").and_then(|id| id.as_u64()), + HttpResponse::InternalServerError().json(JsonRpcResponse::new_error( + req_id, -32603, &format!("Internal error: {}", e), )) @@ -97,16 +101,14 @@ fn is_lifecycle_method(method: &str) -> bool { } /// Handle lifecycle requests locally -fn handle_lifecycle_request(request: &Value, method: &str) -> HttpResponse { - let id = request.get("id").and_then(|id| id.as_u64()); - +fn handle_lifecycle_request(request: &JsonRpcRequest, method: &str) -> HttpResponse { + let req_id = request.id.clone(); match method { "initialize" => { // Return capabilities advertised by the orchestrator - let response = json!({ - "jsonrpc": "2.0", - "id": id, - "result": { + let response = JsonRpcResponse::new_result( + req_id, + json!({ "capabilities": { "hoverProvider": true, "declarationProvider": true, @@ -118,10 +120,10 @@ fn handle_lifecycle_request(request: &Value, method: &str) -> HttpResponse { }, "serverInfo": { "name": "nuanced-lsp", - "version": env!("CARGO_PKG_VERSION") - } - } - }); + "version": env!("CARGO_PKG_VERSION"), + }, + }), + ); HttpResponse::Ok().json(response) } "initialized" => { @@ -130,11 +132,7 @@ fn handle_lifecycle_request(request: &Value, method: &str) -> HttpResponse { } "shutdown" => { // Acknowledge shutdown - let response = json!({ - "jsonrpc": "2.0", - "id": id, - "result": null - }); + let response = JsonRpcResponse::new_result(req_id, Value::Null); HttpResponse::Ok().json(response) } "exit" => { @@ -147,16 +145,19 @@ fn handle_lifecycle_request(request: &Value, method: &str) -> HttpResponse { } _ => { // Unknown lifecycle method - HttpResponse::BadRequest().json(create_error_response(id, -32601, "Method not found")) + HttpResponse::BadRequest().json(JsonRpcResponse::new_error( + req_id, + -32601, + "Method not found", + )) } } } /// Extract document URI from JSON-RPC request parameters -fn extract_document_uri(request: &Value) -> Option { +fn extract_document_uri(params: Option<&Value>) -> Option { // Try textDocument.uri - if let Some(uri) = request - .get("params") + if let Some(uri) = params .and_then(|p| p.get("textDocument")) .and_then(|td| td.get("uri")) .and_then(|u| u.as_str()) @@ -165,11 +166,7 @@ fn extract_document_uri(request: &Value) -> Option { } // Try uri directly - if let Some(uri) = request - .get("params") - .and_then(|p| p.get("uri")) - .and_then(|u| u.as_str()) - { + if let Some(uri) = params.and_then(|p| p.get("uri")).and_then(|u| u.as_str()) { return Some(uri.to_string()); } @@ -193,15 +190,3 @@ fn uri_to_path(uri: &str) -> Option { // Unix-style paths Some(path.to_string()) } - -/// Create a JSON-RPC error response -fn create_error_response(id: Option, code: i32, message: &str) -> Value { - json!({ - "jsonrpc": "2.0", - "id": id, - "error": { - "code": code, - "message": message - } - }) -} diff --git a/crates/wrapper/src/handlers/lsp.rs b/crates/wrapper/src/handlers/lsp.rs index 63548a75..bbe9a6c1 100644 --- a/crates/wrapper/src/handlers/lsp.rs +++ b/crates/wrapper/src/handlers/lsp.rs @@ -1,7 +1,7 @@ use crate::AppState; use actix_web::{web, HttpResponse}; -use log::{debug, error}; -use lsproxy_common::api_types::JsonRpcRequest; +use log::{error, info}; +use lsproxy_common::api_types::{JsonRpcRequest, JsonRpcResponse}; /// Forward raw LSP JSON-RPC requests to the LSP server /// @@ -12,22 +12,28 @@ pub async fn lsp( request: web::Json, ) -> HttpResponse { let lsp_req = request.into_inner(); - let req_value = serde_json::to_value(&lsp_req).unwrap_or_else(|_| serde_json::json!({})); + let method = lsp_req.method.clone(); + let req_id = lsp_req.id.clone(); - debug!("Forwarding LSP request: {:?}", req_value); + info!( + "Received LSP request: id={:?} method={}", + &req_id, &lsp_req.method + ); // Forward the request to the LSP server - match app_state.manager.forward_lsp_request(req_value).await { - Ok(response) => HttpResponse::Ok().json(response), + match app_state.manager.lsp(lsp_req).await { + Ok(response) => { + info!( + "Received process response: id={} method={}", + response.id, method + ); + HttpResponse::Ok().json(response) + } Err(e) => { error!("LSP request failed: {}", e); - HttpResponse::InternalServerError().json(serde_json::json!({ - "jsonrpc": "2.0", - "error": { - "code": -32603, - "message": format!("Internal error: {}", e) - } - })) + let error = + JsonRpcResponse::new_error(req_id, -32603, format!("Internal error: {}", e)); + HttpResponse::InternalServerError().json(error) } } } diff --git a/crates/wrapper/src/manager.rs b/crates/wrapper/src/manager.rs index 4e287985..4ff6c724 100644 --- a/crates/wrapper/src/manager.rs +++ b/crates/wrapper/src/manager.rs @@ -5,7 +5,9 @@ use lsp_types::{GotoDefinitionResponse, Location, Position, Range}; /// /// Unlike the main LSProxy Manager that orchestrates multiple language servers, /// this Manager wraps a single LSP client for the configured language. -use lsproxy_common::api_types::{get_mount_dir, Identifier, Symbol}; +use lsproxy_common::api_types::{ + get_mount_dir, Identifier, JsonRpcRequest, JsonRpcResponse, Symbol, +}; use lsproxy_common::ast_grep::client::AstGrepClient; use lsproxy_common::ast_grep::types::AstGrepMatch; use lsproxy_common::utils::file_utils::uri_to_relative_path_string; @@ -285,25 +287,13 @@ impl Manager { /// Forward a raw LSP JSON-RPC request to the LSP server /// /// This provides lightweight pass-through of JSON-RPC requests with minimal processing. - pub async fn forward_lsp_request( - &self, - request: serde_json::Value, - ) -> Result { - // Parse method from request - let method = request - .get("method") - .and_then(|m| m.as_str()) - .ok_or_else(|| { - LspManagerError::InternalError("Missing method in LSP request".to_string()) - })?; - - // Get params from request - let params = request.get("params").cloned(); + pub async fn lsp(&self, request: JsonRpcRequest) -> Result { + let req_id = request.id; // Forward to LSP server let mut locked_client = self.client.lock().await; let result = locked_client - .send_request(method, params) + .send_request(&request.method, request.params) .await .map_err(|e| { error!("Failed to forward LSP request: {}", e); @@ -311,11 +301,7 @@ impl Manager { })?; // Build response - let response = serde_json::json!({ - "jsonrpc": "2.0", - "id": request.get("id"), - "result": result - }); + let response = JsonRpcResponse::new_result(req_id, result); Ok(response) } From e805af988b197ed5ccfb7babb8cee512c83cc217 Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Mon, 17 Nov 2025 16:09:44 +0100 Subject: [PATCH 03/92] Use type for server capabilities too --- crates/orchestrator/src/handlers/lsp.rs | 37 ++++++++++++++----------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/crates/orchestrator/src/handlers/lsp.rs b/crates/orchestrator/src/handlers/lsp.rs index 804c64ef..dd6fc312 100644 --- a/crates/orchestrator/src/handlers/lsp.rs +++ b/crates/orchestrator/src/handlers/lsp.rs @@ -3,8 +3,12 @@ use crate::AppState; use actix_web::web::{Data, Json}; use actix_web::HttpResponse; use log::{error, info, warn}; +use lsp_types::{ + DeclarationCapability, HoverProviderCapability, InitializeResult, OneOf, PositionEncodingKind, + ServerCapabilities, ServerInfo, +}; use lsproxy_common::api_types::{JsonRpcRequest, JsonRpcResponse}; -use serde_json::{json, Value}; +use serde_json::Value; /// Forward LSP JSON-RPC requests to language servers #[utoipa::path( @@ -106,23 +110,24 @@ fn handle_lifecycle_request(request: &JsonRpcRequest, method: &str) -> HttpRespo match method { "initialize" => { // Return capabilities advertised by the orchestrator + let mut capabilities = ServerCapabilities::default(); + capabilities.call_hierarchy_provider = Some(true.into()); + capabilities.declaration_provider = Some(DeclarationCapability::Simple(true)); + capabilities.definition_provider = Some(OneOf::Left(true)); + capabilities.document_symbol_provider = Some(OneOf::Left(true)); + capabilities.hover_provider = Some(HoverProviderCapability::Simple(true)); + capabilities.position_encoding = Some(PositionEncodingKind::UTF8); + capabilities.references_provider = Some(OneOf::Left(true)); + capabilities.type_definition_provider = Some(true.into()); let response = JsonRpcResponse::new_result( req_id, - json!({ - "capabilities": { - "hoverProvider": true, - "declarationProvider": true, - "definitionProvider": true, - "typeDefinitionProvider": true, - "referencesProvider": true, - "documentSymbolProvider": true, - "typeHierarchyProvider": true, - }, - "serverInfo": { - "name": "nuanced-lsp", - "version": env!("CARGO_PKG_VERSION"), - }, - }), + InitializeResult { + capabilities, + server_info: Some(ServerInfo { + name: "nuanced-lsp".to_string(), + version: Some(env!("CARGO_PKG_VERSION").to_string()), + }), + }, ); HttpResponse::Ok().json(response) } From 06e8257a3a837686a02a1f3b6b1967fc7ea85d72 Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Mon, 17 Nov 2025 18:05:23 +0100 Subject: [PATCH 04/92] Be explicit about using UTF-16 for LSP --- crates/orchestrator/src/handlers/lsp.rs | 4 ++-- crates/wrapper/src/lsp/client.rs | 13 +++++++++---- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/crates/orchestrator/src/handlers/lsp.rs b/crates/orchestrator/src/handlers/lsp.rs index dd6fc312..8394a684 100644 --- a/crates/orchestrator/src/handlers/lsp.rs +++ b/crates/orchestrator/src/handlers/lsp.rs @@ -115,8 +115,8 @@ fn handle_lifecycle_request(request: &JsonRpcRequest, method: &str) -> HttpRespo capabilities.declaration_provider = Some(DeclarationCapability::Simple(true)); capabilities.definition_provider = Some(OneOf::Left(true)); capabilities.document_symbol_provider = Some(OneOf::Left(true)); - capabilities.hover_provider = Some(HoverProviderCapability::Simple(true)); - capabilities.position_encoding = Some(PositionEncodingKind::UTF8); + capabilities.hover_provider = Some(HoverProviderCapability::Simple(false)); + capabilities.position_encoding = Some(PositionEncodingKind::UTF16); capabilities.references_provider = Some(OneOf::Left(true)); capabilities.type_definition_provider = Some(true.into()); let response = JsonRpcResponse::new_result( diff --git a/crates/wrapper/src/lsp/client.rs b/crates/wrapper/src/lsp/client.rs index ff943be0..5272f23b 100644 --- a/crates/wrapper/src/lsp/client.rs +++ b/crates/wrapper/src/lsp/client.rs @@ -5,10 +5,11 @@ use async_trait::async_trait; use log::{debug, error, info, warn}; use lsp_types::{ ClientCapabilities, DidOpenTextDocumentParams, DocumentSymbolClientCapabilities, - GotoDefinitionParams, GotoDefinitionResponse, InitializeParams, InitializeResult, Location, - PartialResultParams, Position, PublishDiagnosticsClientCapabilities, ReferenceContext, - ReferenceParams, TagSupport, TextDocumentClientCapabilities, TextDocumentIdentifier, - TextDocumentItem, TextDocumentPositionParams, Url, WorkDoneProgressParams, WorkspaceFolder, + GeneralClientCapabilities, GotoDefinitionParams, GotoDefinitionResponse, InitializeParams, + InitializeResult, Location, PartialResultParams, Position, PositionEncodingKind, + PublishDiagnosticsClientCapabilities, ReferenceContext, ReferenceParams, TagSupport, + TextDocumentClientCapabilities, TextDocumentIdentifier, TextDocumentItem, + TextDocumentPositionParams, Url, WorkDoneProgressParams, WorkspaceFolder, }; use lsproxy_common::utils::file_utils::{fix_relative_uris, search_paths, FileType}; use lsproxy_common::utils::language_utils::detect_language_string; @@ -44,6 +45,10 @@ pub trait LspClient: Send { fn get_capabilities(&mut self) -> ClientCapabilities { let mut capabilities = ClientCapabilities::default(); + capabilities.general = Some(GeneralClientCapabilities { + position_encodings: Some(vec![PositionEncodingKind::UTF16]), + ..Default::default() + }); capabilities.text_document = Some(TextDocumentClientCapabilities { document_symbol: Some(DocumentSymbolClientCapabilities { dynamic_registration: Some(false), From e1bdef78d144e850b03bc95d8706f361615c55ed Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Mon, 17 Nov 2025 18:07:16 +0100 Subject: [PATCH 05/92] Add debug messages --- crates/orchestrator/src/handlers/lsp.rs | 14 ++++---------- crates/wrapper/src/handlers/lsp.rs | 10 ++++------ 2 files changed, 8 insertions(+), 16 deletions(-) diff --git a/crates/orchestrator/src/handlers/lsp.rs b/crates/orchestrator/src/handlers/lsp.rs index 8394a684..392f6271 100644 --- a/crates/orchestrator/src/handlers/lsp.rs +++ b/crates/orchestrator/src/handlers/lsp.rs @@ -2,7 +2,7 @@ use crate::handlers::container_proxy; use crate::AppState; use actix_web::web::{Data, Json}; use actix_web::HttpResponse; -use log::{error, info, warn}; +use log::{debug, error, info, warn}; use lsp_types::{ DeclarationCapability, HoverProviderCapability, InitializeResult, OneOf, PositionEncodingKind, ServerCapabilities, ServerInfo, @@ -28,6 +28,7 @@ pub async fn lsp(data: Data, request: Json) -> HttpRes let method = &lsp_req.method; let req_id = lsp_req.id.clone(); info!("Received LSP request: id={:?} method={}", &req_id, method); + debug!("LSP request: id={:?}", &lsp_req); // Handle lifecycle requests locally if is_lifecycle_method(method) { @@ -79,10 +80,8 @@ pub async fn lsp(data: Data, request: Json) -> HttpRes // Forward request to container match client.forward_lsp_request(&lsp_req).await { Ok(response) => { - info!( - "Received container response: id={} method={}", - response.id, method - ); + info!("Received container response: id={}", response.id); + debug!("Container response: {:?}", response); HttpResponse::Ok().json(response) } Err(e) => { @@ -170,11 +169,6 @@ fn extract_document_uri(params: Option<&Value>) -> Option { return Some(uri.to_string()); } - // Try uri directly - if let Some(uri) = params.and_then(|p| p.get("uri")).and_then(|u| u.as_str()) { - return Some(uri.to_string()); - } - None } diff --git a/crates/wrapper/src/handlers/lsp.rs b/crates/wrapper/src/handlers/lsp.rs index bbe9a6c1..07ada2fb 100644 --- a/crates/wrapper/src/handlers/lsp.rs +++ b/crates/wrapper/src/handlers/lsp.rs @@ -1,6 +1,6 @@ use crate::AppState; use actix_web::{web, HttpResponse}; -use log::{error, info}; +use log::{debug, error, info}; use lsproxy_common::api_types::{JsonRpcRequest, JsonRpcResponse}; /// Forward raw LSP JSON-RPC requests to the LSP server @@ -12,21 +12,19 @@ pub async fn lsp( request: web::Json, ) -> HttpResponse { let lsp_req = request.into_inner(); - let method = lsp_req.method.clone(); let req_id = lsp_req.id.clone(); info!( "Received LSP request: id={:?} method={}", &req_id, &lsp_req.method ); + debug!("LSP request: {:?}", &lsp_req); // Forward the request to the LSP server match app_state.manager.lsp(lsp_req).await { Ok(response) => { - info!( - "Received process response: id={} method={}", - response.id, method - ); + info!("Received process response: id={}", response.id); + debug!("Process response: {:?}", response); HttpResponse::Ok().json(response) } Err(e) => { From f803d263d6411c1ec86cb577069b1f4a23b6a53c Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Mon, 17 Nov 2025 21:18:28 +0100 Subject: [PATCH 06/92] Convert paths between host and containers --- crates/orchestrator/src/container/mod.rs | 56 ++++++++ crates/orchestrator/src/handlers/lsp.rs | 167 ++++++++++++++++++++--- 2 files changed, 204 insertions(+), 19 deletions(-) diff --git a/crates/orchestrator/src/container/mod.rs b/crates/orchestrator/src/container/mod.rs index 3e6a3e4e..2a42d11b 100644 --- a/crates/orchestrator/src/container/mod.rs +++ b/crates/orchestrator/src/container/mod.rs @@ -224,6 +224,62 @@ impl ContainerOrchestrator { } } + /// Convert a host path to a container path + /// Only converts absolute paths; relative paths are returned unchanged + pub async fn convert_path_host_to_container(&self, path: &str) -> Option { + use std::path::Path; + + let path_obj = Path::new(path); + + // Only convert absolute paths + if !path_obj.is_absolute() { + return Some(path.to_string()); + } + + // Get the host workspace path + let host_workspace = self.get_host_workspace_path().await?; + let host_workspace_path = Path::new(&host_workspace); + + // Check if the path starts with the host workspace path + if let Ok(relative) = path_obj.strip_prefix(host_workspace_path) { + // Convert to container path + let container_path = Path::new("/mnt/workspace").join(relative); + Some(container_path.to_string_lossy().to_string()) + } else { + // Path is absolute but not in workspace - return as is + Some(path.to_string()) + } + } + + /// Convert a container path to a host path + /// Only converts absolute paths; relative paths are returned unchanged + pub async fn convert_path_container_to_host(&self, path: &str) -> Option { + use std::path::Path; + + let path_obj = Path::new(path); + + // Only convert absolute paths + if !path_obj.is_absolute() { + return Some(path.to_string()); + } + + let container_workspace = Path::new("/mnt/workspace"); + + // Check if the path starts with the container workspace path + if let Ok(relative) = path_obj.strip_prefix(container_workspace) { + // Get the host workspace path + let host_workspace = self.get_host_workspace_path().await?; + let host_workspace_path = Path::new(&host_workspace); + + // Convert to host path + let host_path = host_workspace_path.join(relative); + Some(host_path.to_string_lossy().to_string()) + } else { + // Path is absolute but not in container workspace - return as is + Some(path.to_string()) + } + } + /// Parse a language string (case-insensitive, handles aliases) fn parse_language(s: &str) -> Option { match s.trim().to_lowercase().as_str() { diff --git a/crates/orchestrator/src/handlers/lsp.rs b/crates/orchestrator/src/handlers/lsp.rs index 392f6271..6e16baec 100644 --- a/crates/orchestrator/src/handlers/lsp.rs +++ b/crates/orchestrator/src/handlers/lsp.rs @@ -1,3 +1,4 @@ +use crate::container::ContainerOrchestrator; use crate::handlers::container_proxy; use crate::AppState; use actix_web::web::{Data, Json}; @@ -9,6 +10,8 @@ use lsp_types::{ }; use lsproxy_common::api_types::{JsonRpcRequest, JsonRpcResponse}; use serde_json::Value; +use std::sync::Arc; +use url::Url; /// Forward LSP JSON-RPC requests to language servers #[utoipa::path( @@ -52,10 +55,28 @@ pub async fn lsp(data: Data, request: Json) -> HttpRes }; // Convert file:// URI to path - let file_path = match uri_to_path(&document_uri) { - Some(path) => path, - None => { - error!("Invalid document URI: {}", document_uri); + let file_path = match Url::parse(&document_uri) { + Ok(url) if url.scheme() == "file" => match url.to_file_path() { + Ok(path) => path.to_string_lossy().to_string(), + Err(_) => { + error!("Invalid file URI path: {}", document_uri); + return HttpResponse::BadRequest().json(JsonRpcResponse::new_error( + req_id, + -32602, + "Invalid params: invalid file URI path", + )); + } + }, + Ok(_) => { + error!("Non-file URI: {}", document_uri); + return HttpResponse::BadRequest().json(JsonRpcResponse::new_error( + req_id, + -32602, + "Invalid params: expected file URI", + )); + } + Err(e) => { + error!("Invalid document URI: {} - {}", document_uri, e); return HttpResponse::BadRequest().json(JsonRpcResponse::new_error( req_id, -32602, @@ -77,11 +98,39 @@ pub async fn lsp(data: Data, request: Json) -> HttpRes } }; + // Convert request paths from host to container + let mut converted_request = lsp_req.clone(); + if let Some(ref mut params) = converted_request.params { + if let Err(e) = convert_json_paths_host_to_container(&data.orchestrator, params).await { + error!("Failed to convert request paths: {}", e); + return HttpResponse::InternalServerError().json(JsonRpcResponse::new_error( + req_id, + -32603, + &format!("Path conversion error: {}", e), + )); + } + } + // Forward request to container - match client.forward_lsp_request(&lsp_req).await { - Ok(response) => { + match client.forward_lsp_request(&converted_request).await { + Ok(mut response) => { info!("Received container response: id={}", response.id); debug!("Container response: {:?}", response); + + // Convert response paths from container to host + if let Some(ref mut result) = response.result { + if let Err(e) = + convert_json_paths_container_to_host(&data.orchestrator, result).await + { + error!("Failed to convert response paths: {}", e); + return HttpResponse::InternalServerError().json(JsonRpcResponse::new_error( + req_id, + -32603, + &format!("Path conversion error: {}", e), + )); + } + } + HttpResponse::Ok().json(response) } Err(e) => { @@ -172,20 +221,100 @@ fn extract_document_uri(params: Option<&Value>) -> Option { None } -/// Convert file:// URI to file path -fn uri_to_path(uri: &str) -> Option { - if !uri.starts_with("file://") { - return None; - } +/// Recursively convert paths in JSON value from host to container +fn convert_json_paths_host_to_container<'a>( + orchestrator: &'a Arc, + value: &'a mut Value, +) -> std::pin::Pin> + 'a>> { + Box::pin(async move { + match value { + Value::String(s) => { + // Try to parse as URI + if let Ok(url) = Url::parse(s) { + // Only process file URIs + if url.scheme() == "file" { + // Extract path from URI + if let Ok(path) = url.to_file_path() { + let path_str = path.to_string_lossy().to_string(); - // Remove file:// prefix - let path = &uri[7..]; + // Convert path if it's absolute + if let Some(converted) = + orchestrator.convert_path_host_to_container(&path_str).await + { + // Only update if path changed + if converted != path_str { + // Create new URI from converted path + let converted_path = std::path::PathBuf::from(&converted); + if let Ok(new_url) = Url::from_file_path(&converted_path) { + *s = new_url.to_string(); + } + } + } + } + } + } + } + Value::Object(map) => { + for (_, v) in map.iter_mut() { + convert_json_paths_host_to_container(orchestrator, v).await?; + } + } + Value::Array(arr) => { + for item in arr.iter_mut() { + convert_json_paths_host_to_container(orchestrator, item).await?; + } + } + _ => {} + } + Ok(()) + }) +} - // Handle Windows-style paths (file:///C:/...) - if path.starts_with('/') && path.len() > 2 && path.chars().nth(2) == Some(':') { - return Some(path[1..].to_string()); - } +/// Recursively convert paths in JSON value from container to host +fn convert_json_paths_container_to_host<'a>( + orchestrator: &'a Arc, + value: &'a mut Value, +) -> std::pin::Pin> + 'a>> { + Box::pin(async move { + match value { + Value::String(s) => { + // Try to parse as URI + if let Ok(url) = Url::parse(s) { + // Only process file URIs + if url.scheme() == "file" { + // Extract path from URI + if let Ok(path) = url.to_file_path() { + let path_str = path.to_string_lossy().to_string(); - // Unix-style paths - Some(path.to_string()) + // Convert path if it's absolute + if let Some(converted) = + orchestrator.convert_path_container_to_host(&path_str).await + { + // Only update if path changed + if converted != path_str { + // Create new URI from converted path + let converted_path = std::path::PathBuf::from(&converted); + if let Ok(new_url) = Url::from_file_path(&converted_path) { + *s = new_url.to_string(); + } + } + } + } + } + } + } + Value::Object(map) => { + for (_, v) in map.iter_mut() { + convert_json_paths_container_to_host(orchestrator, v).await?; + } + } + Value::Array(arr) => { + for item in arr.iter_mut() { + convert_json_paths_container_to_host(orchestrator, item).await?; + } + } + _ => {} + } + Ok(()) + }) } From bc3ed2c7df9e9211897dd19496f7c370df632e58 Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Mon, 17 Nov 2025 21:30:27 +0100 Subject: [PATCH 07/92] Add note about lsp-types upgrade --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index d3bb7a4b..1053250e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,7 +21,7 @@ log = "0.4" env_logger = "0.10" clap = { version = "4.3", features = ["derive"] } thiserror = "1.0" -lsp-types = "0.95" +lsp-types = "0.95" # Upgrade needs resolution or workaround for https://github.com/gluon-lang/lsp-types/issues/284 async-trait = "0.1" glob = "0.3" ignore = "0.4.23" # Pin to avoid edition2024 requirement in 0.4.25+ From eb6544fbb47d274757cfa8ee83914d0c667c5d32 Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Tue, 18 Nov 2025 13:07:15 +0100 Subject: [PATCH 08/92] Distinguish missing fields and null values --- crates/common/src/api_types.rs | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/crates/common/src/api_types.rs b/crates/common/src/api_types.rs index 8122ee5f..5fc2d07e 100644 --- a/crates/common/src/api_types.rs +++ b/crates/common/src/api_types.rs @@ -1,5 +1,5 @@ use lsp_types::{Location, LocationLink}; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; use serde_json::Value; use std::cell::RefCell; use std::collections::HashMap; @@ -582,7 +582,11 @@ pub struct JsonRpcRequest { pub method: String, /// The method parameters (structure varies by method) - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + default, + skip_serializing_if = "Option::is_none", + deserialize_with = "deserialize_option_value" + )] pub params: Option, } @@ -612,7 +616,11 @@ pub struct JsonRpcResponse { pub id: Value, /// The result (present on success) - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + default, + skip_serializing_if = "Option::is_none", + deserialize_with = "deserialize_option_value" + )] pub result: Option, /// The error (present on failure) @@ -662,6 +670,23 @@ pub struct JsonRpcError { pub data: Option, } +/// Custom deserialize function to ensure `Some(Null)` is not reduced to `None`. +/// Fields need to be annotated as follows: +/// ```skip +/// #[serde( +/// default, +/// skip_serializing_if = "Option::is_none", +/// deserialize_with = "deserialize_option_value" +/// )] +/// ``` +/// Solution from: +fn deserialize_option_value<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + Deserialize::deserialize(deserializer).map(Some) +} + #[cfg(test)] mod tests { use super::*; From 84a2eb6cfd072e9cdfb1c91343a1e965274a5d99 Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Tue, 18 Nov 2025 13:51:43 +0100 Subject: [PATCH 09/92] Generalize host-container path conversion --- crates/orchestrator/src/container/mod.rs | 56 ------------ crates/orchestrator/src/handlers/lsp.rs | 104 +++++++---------------- 2 files changed, 29 insertions(+), 131 deletions(-) diff --git a/crates/orchestrator/src/container/mod.rs b/crates/orchestrator/src/container/mod.rs index 2a42d11b..3e6a3e4e 100644 --- a/crates/orchestrator/src/container/mod.rs +++ b/crates/orchestrator/src/container/mod.rs @@ -224,62 +224,6 @@ impl ContainerOrchestrator { } } - /// Convert a host path to a container path - /// Only converts absolute paths; relative paths are returned unchanged - pub async fn convert_path_host_to_container(&self, path: &str) -> Option { - use std::path::Path; - - let path_obj = Path::new(path); - - // Only convert absolute paths - if !path_obj.is_absolute() { - return Some(path.to_string()); - } - - // Get the host workspace path - let host_workspace = self.get_host_workspace_path().await?; - let host_workspace_path = Path::new(&host_workspace); - - // Check if the path starts with the host workspace path - if let Ok(relative) = path_obj.strip_prefix(host_workspace_path) { - // Convert to container path - let container_path = Path::new("/mnt/workspace").join(relative); - Some(container_path.to_string_lossy().to_string()) - } else { - // Path is absolute but not in workspace - return as is - Some(path.to_string()) - } - } - - /// Convert a container path to a host path - /// Only converts absolute paths; relative paths are returned unchanged - pub async fn convert_path_container_to_host(&self, path: &str) -> Option { - use std::path::Path; - - let path_obj = Path::new(path); - - // Only convert absolute paths - if !path_obj.is_absolute() { - return Some(path.to_string()); - } - - let container_workspace = Path::new("/mnt/workspace"); - - // Check if the path starts with the container workspace path - if let Ok(relative) = path_obj.strip_prefix(container_workspace) { - // Get the host workspace path - let host_workspace = self.get_host_workspace_path().await?; - let host_workspace_path = Path::new(&host_workspace); - - // Convert to host path - let host_path = host_workspace_path.join(relative); - Some(host_path.to_string_lossy().to_string()) - } else { - // Path is absolute but not in container workspace - return as is - Some(path.to_string()) - } - } - /// Parse a language string (case-insensitive, handles aliases) fn parse_language(s: &str) -> Option { match s.trim().to_lowercase().as_str() { diff --git a/crates/orchestrator/src/handlers/lsp.rs b/crates/orchestrator/src/handlers/lsp.rs index 6e16baec..9fbd90f6 100644 --- a/crates/orchestrator/src/handlers/lsp.rs +++ b/crates/orchestrator/src/handlers/lsp.rs @@ -227,47 +227,34 @@ fn convert_json_paths_host_to_container<'a>( value: &'a mut Value, ) -> std::pin::Pin> + 'a>> { Box::pin(async move { - match value { - Value::String(s) => { - // Try to parse as URI - if let Ok(url) = Url::parse(s) { - // Only process file URIs - if url.scheme() == "file" { - // Extract path from URI - if let Ok(path) = url.to_file_path() { - let path_str = path.to_string_lossy().to_string(); + let host_workspace = orchestrator + .get_host_workspace_path() + .await + .ok_or_else(|| "Failed to get host workspace path".to_string())?; - // Convert path if it's absolute - if let Some(converted) = - orchestrator.convert_path_host_to_container(&path_str).await - { - // Only update if path changed - if converted != path_str { - // Create new URI from converted path - let converted_path = std::path::PathBuf::from(&converted); - if let Ok(new_url) = Url::from_file_path(&converted_path) { - *s = new_url.to_string(); - } - } - } - } - } - } - } - Value::Object(map) => { - for (_, v) in map.iter_mut() { - convert_json_paths_host_to_container(orchestrator, v).await?; - } + convert_json_paths_recursive(value, &host_workspace, "/mnt/workspace"); + Ok(()) + }) +} + +/// Recursively replace path strings in JSON values +fn convert_json_paths_recursive(value: &mut Value, from: &str, to: &str) { + match value { + Value::String(s) => { + *s = s.replace(from, to); + } + Value::Object(map) => { + for (_, v) in map.iter_mut() { + convert_json_paths_recursive(v, from, to); } - Value::Array(arr) => { - for item in arr.iter_mut() { - convert_json_paths_host_to_container(orchestrator, item).await?; - } + } + Value::Array(arr) => { + for item in arr.iter_mut() { + convert_json_paths_recursive(item, from, to); } - _ => {} } - Ok(()) - }) + _ => {} + } } /// Recursively convert paths in JSON value from container to host @@ -276,45 +263,12 @@ fn convert_json_paths_container_to_host<'a>( value: &'a mut Value, ) -> std::pin::Pin> + 'a>> { Box::pin(async move { - match value { - Value::String(s) => { - // Try to parse as URI - if let Ok(url) = Url::parse(s) { - // Only process file URIs - if url.scheme() == "file" { - // Extract path from URI - if let Ok(path) = url.to_file_path() { - let path_str = path.to_string_lossy().to_string(); + let host_workspace = orchestrator + .get_host_workspace_path() + .await + .ok_or_else(|| "Failed to get host workspace path".to_string())?; - // Convert path if it's absolute - if let Some(converted) = - orchestrator.convert_path_container_to_host(&path_str).await - { - // Only update if path changed - if converted != path_str { - // Create new URI from converted path - let converted_path = std::path::PathBuf::from(&converted); - if let Ok(new_url) = Url::from_file_path(&converted_path) { - *s = new_url.to_string(); - } - } - } - } - } - } - } - Value::Object(map) => { - for (_, v) in map.iter_mut() { - convert_json_paths_container_to_host(orchestrator, v).await?; - } - } - Value::Array(arr) => { - for item in arr.iter_mut() { - convert_json_paths_container_to_host(orchestrator, item).await?; - } - } - _ => {} - } + convert_json_paths_recursive(value, "/mnt/workspace", &host_workspace); Ok(()) }) } From 2d4f2843ad275c7c134c5351868e4773c3aca8fd Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Tue, 18 Nov 2025 17:59:00 +0100 Subject: [PATCH 10/92] Simplify code for lifecycle methods --- crates/orchestrator/src/handlers/lsp.rs | 52 ++++++++----------------- 1 file changed, 16 insertions(+), 36 deletions(-) diff --git a/crates/orchestrator/src/handlers/lsp.rs b/crates/orchestrator/src/handlers/lsp.rs index 9fbd90f6..85d2b51e 100644 --- a/crates/orchestrator/src/handlers/lsp.rs +++ b/crates/orchestrator/src/handlers/lsp.rs @@ -34,8 +34,8 @@ pub async fn lsp(data: Data, request: Json) -> HttpRes debug!("LSP request: id={:?}", &lsp_req); // Handle lifecycle requests locally - if is_lifecycle_method(method) { - return handle_lifecycle_request(&lsp_req, method); + if let Some(response) = handle_lifecycle_request(&lsp_req) { + return response; } // For language feature requests, extract document URI and route to appropriate backend @@ -144,20 +144,11 @@ pub async fn lsp(data: Data, request: Json) -> HttpRes } } -/// Check if a method is a lifecycle method that should be handled by the orchestrator -fn is_lifecycle_method(method: &str) -> bool { - matches!( - method, - "initialize" | "initialized" | "shutdown" | "exit" | "$/cancelRequest" | "$/setTrace" - ) -} - /// Handle lifecycle requests locally -fn handle_lifecycle_request(request: &JsonRpcRequest, method: &str) -> HttpResponse { +fn handle_lifecycle_request(request: &JsonRpcRequest) -> Option { let req_id = request.id.clone(); - match method { + match request.method.as_str() { "initialize" => { - // Return capabilities advertised by the orchestrator let mut capabilities = ServerCapabilities::default(); capabilities.call_hierarchy_provider = Some(true.into()); capabilities.declaration_provider = Some(DeclarationCapability::Simple(true)); @@ -177,33 +168,22 @@ fn handle_lifecycle_request(request: &JsonRpcRequest, method: &str) -> HttpRespo }), }, ); - HttpResponse::Ok().json(response) - } - "initialized" => { - // Notification - no response needed - HttpResponse::Ok().finish() + Some(HttpResponse::Ok().json(response)) } + "initialized" | "exit" => Some(HttpResponse::Ok().finish()), "shutdown" => { - // Acknowledge shutdown let response = JsonRpcResponse::new_result(req_id, Value::Null); - HttpResponse::Ok().json(response) - } - "exit" => { - // Notification - no response needed - HttpResponse::Ok().finish() - } - "$/cancelRequest" | "$/setTrace" => { - // These are notifications or special requests - acknowledge - HttpResponse::Ok().finish() - } - _ => { - // Unknown lifecycle method - HttpResponse::BadRequest().json(JsonRpcResponse::new_error( - req_id, - -32601, - "Method not found", - )) + Some(HttpResponse::Ok().json(response)) + } + dollar_method if dollar_method.starts_with("$/") => { + if req_id.is_some() { + let response = JsonRpcResponse::new_error(req_id, -32601, "Method not found"); + Some(HttpResponse::Ok().json(response)) + } else { + Some(HttpResponse::Ok().finish()) + } } + _ => None, } } From 1a474b7037218b5abfaf2a27114b51d416efcb3b Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Tue, 18 Nov 2025 18:18:51 +0100 Subject: [PATCH 11/92] Add capabilities --- crates/orchestrator/src/handlers/lsp.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/orchestrator/src/handlers/lsp.rs b/crates/orchestrator/src/handlers/lsp.rs index 85d2b51e..cea15f7f 100644 --- a/crates/orchestrator/src/handlers/lsp.rs +++ b/crates/orchestrator/src/handlers/lsp.rs @@ -5,7 +5,8 @@ use actix_web::web::{Data, Json}; use actix_web::HttpResponse; use log::{debug, error, info, warn}; use lsp_types::{ - DeclarationCapability, HoverProviderCapability, InitializeResult, OneOf, PositionEncodingKind, + DeclarationCapability, FoldingRangeProviderCapability, HoverProviderCapability, + ImplementationProviderCapability, InitializeResult, OneOf, PositionEncodingKind, ServerCapabilities, ServerInfo, }; use lsproxy_common::api_types::{JsonRpcRequest, JsonRpcResponse}; @@ -154,7 +155,12 @@ fn handle_lifecycle_request(request: &JsonRpcRequest) -> Option { capabilities.declaration_provider = Some(DeclarationCapability::Simple(true)); capabilities.definition_provider = Some(OneOf::Left(true)); capabilities.document_symbol_provider = Some(OneOf::Left(true)); + capabilities.folding_range_provider = + Some(FoldingRangeProviderCapability::Simple(true)); capabilities.hover_provider = Some(HoverProviderCapability::Simple(false)); + capabilities.implementation_provider = + Some(ImplementationProviderCapability::Simple(true)); + capabilities.inline_value_provider = Some(OneOf::Left(true)); capabilities.position_encoding = Some(PositionEncodingKind::UTF16); capabilities.references_provider = Some(OneOf::Left(true)); capabilities.type_definition_provider = Some(true.into()); From 02f307c776e9fc7c0f58c1c32eb89c5266df1180 Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Wed, 19 Nov 2025 15:10:40 +0100 Subject: [PATCH 12/92] Add annotation to wrapper LSP endpoint --- crates/wrapper/src/handlers/lsp.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/wrapper/src/handlers/lsp.rs b/crates/wrapper/src/handlers/lsp.rs index 07ada2fb..b2c44d48 100644 --- a/crates/wrapper/src/handlers/lsp.rs +++ b/crates/wrapper/src/handlers/lsp.rs @@ -7,6 +7,16 @@ use lsproxy_common::api_types::{JsonRpcRequest, JsonRpcResponse}; /// /// This endpoint provides direct access to the LSP server by forwarding /// JSON-RPC requests and returning responses with minimal processing. +#[utoipa::path( + post, + path = "/lsp", + request_body = JsonRpcRequest, + responses( + (status = 200, description = "Identifier retrieved successfully", body = JsonRpcResponse), + (status = 400, description = "Bad request"), + (status = 500, description = "Internal server error") + ) +)] pub async fn lsp( app_state: web::Data, request: web::Json, From c7f851726beaf4b4e2e2cdff6f6e0afa61831449 Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Mon, 24 Nov 2025 14:13:39 +0100 Subject: [PATCH 13/92] Accept textdocument sync notifications --- crates/orchestrator/src/handlers/lsp.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/orchestrator/src/handlers/lsp.rs b/crates/orchestrator/src/handlers/lsp.rs index cea15f7f..e3e48ed2 100644 --- a/crates/orchestrator/src/handlers/lsp.rs +++ b/crates/orchestrator/src/handlers/lsp.rs @@ -7,7 +7,7 @@ use log::{debug, error, info, warn}; use lsp_types::{ DeclarationCapability, FoldingRangeProviderCapability, HoverProviderCapability, ImplementationProviderCapability, InitializeResult, OneOf, PositionEncodingKind, - ServerCapabilities, ServerInfo, + ServerCapabilities, ServerInfo, TextDocumentSyncKind, TextDocumentSyncOptions, }; use lsproxy_common::api_types::{JsonRpcRequest, JsonRpcResponse}; use serde_json::Value; @@ -163,6 +163,14 @@ fn handle_lifecycle_request(request: &JsonRpcRequest) -> Option { capabilities.inline_value_provider = Some(OneOf::Left(true)); capabilities.position_encoding = Some(PositionEncodingKind::UTF16); capabilities.references_provider = Some(OneOf::Left(true)); + capabilities.text_document_sync = Some( + TextDocumentSyncOptions { + open_close: Some(true), + change: Some(TextDocumentSyncKind::FULL), + ..Default::default() + } + .into(), + ); capabilities.type_definition_provider = Some(true.into()); let response = JsonRpcResponse::new_result( req_id, From 162a155b247a28a3b2f60ee6169ac4f2668e0792 Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Mon, 24 Nov 2025 14:41:43 +0100 Subject: [PATCH 14/92] Simplify notification sending --- crates/wrapper/src/lsp/client.rs | 39 ++++++++++++++++-------------- crates/wrapper/src/lsp/json_rpc.rs | 6 ++--- 2 files changed, 24 insertions(+), 21 deletions(-) diff --git a/crates/wrapper/src/lsp/client.rs b/crates/wrapper/src/lsp/client.rs index 5272f23b..ffdeed73 100644 --- a/crates/wrapper/src/lsp/client.rs +++ b/crates/wrapper/src/lsp/client.rs @@ -87,6 +87,23 @@ pub trait LspClient: Send { Ok(params) } + async fn send_notification( + &mut self, + method: &str, + params: Option, + ) -> Result<(), Box> { + let notification = self.get_json_rpc().create_notification(method, params); + + let message = format!( + "Content-Length: {}\r\n\r\n{}", + notification.len(), + notification + ); + debug!("Message: {:?}", message); + self.get_process().send(&message).await?; + Ok(()) + } + async fn send_request( &mut self, method: &str, @@ -168,15 +185,8 @@ pub trait LspClient: Send { async fn send_initialized(&mut self) -> Result<(), Box> { debug!("Sending 'initialized' notification"); - let notification = self - .get_json_rpc() - .create_notification("initialized", serde_json::json!({})); - let message = format!( - "Content-Length: {}\r\n\r\n{}", - notification.len(), - notification - ); - self.get_process().send(&message).await + self.send_notification("initialized", Some(serde_json::json!({}))) + .await } async fn text_document_did_open( @@ -186,15 +196,8 @@ pub trait LspClient: Send { let params = DidOpenTextDocumentParams { text_document: item, }; - let notification = self - .get_json_rpc() - .create_notification("textDocument/didOpen", serde_json::to_value(params)?); - let message = format!( - "Content-Length: {}\r\n\r\n{}", - notification.len(), - notification - ); - self.get_process().send(&message).await + self.send_notification("textDocument/didOpen", Some(serde_json::to_value(params)?)) + .await } async fn text_document_definition( diff --git a/crates/wrapper/src/lsp/json_rpc.rs b/crates/wrapper/src/lsp/json_rpc.rs index 348a8c86..d1b7b19b 100644 --- a/crates/wrapper/src/lsp/json_rpc.rs +++ b/crates/wrapper/src/lsp/json_rpc.rs @@ -11,7 +11,7 @@ use tokio::sync::Mutex; pub trait JsonRpc: Send + Sync { fn create_success_response(&self, id: u64) -> String; fn create_request(&self, method: &str, params: Option) -> (u64, String); - fn create_notification(&self, method: &str, params: Value) -> String; + fn create_notification(&self, method: &str, params: Option) -> String; fn parse_message(&self, data: &str) -> Result; } @@ -75,11 +75,11 @@ impl JsonRpc for JsonRpcHandler { (id, request) } - fn create_notification(&self, method: &str, params: Value) -> String { + fn create_notification(&self, method: &str, params: Option) -> String { serde_json::json!({ "jsonrpc": "2.0", "method": method, - "params": params + "params": params.unwrap_or(serde_json::Value::Null) }) .to_string() } From 442083beea8ed4faae12509e37e93e4efbc420bf Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Mon, 24 Nov 2025 14:48:13 +0100 Subject: [PATCH 15/92] Handle stdio message format in one place --- crates/wrapper/src/lsp/client.rs | 21 +++------------------ crates/wrapper/src/lsp/process.rs | 6 ++++-- 2 files changed, 7 insertions(+), 20 deletions(-) diff --git a/crates/wrapper/src/lsp/client.rs b/crates/wrapper/src/lsp/client.rs index ffdeed73..e77b9c22 100644 --- a/crates/wrapper/src/lsp/client.rs +++ b/crates/wrapper/src/lsp/client.rs @@ -93,14 +93,7 @@ pub trait LspClient: Send { params: Option, ) -> Result<(), Box> { let notification = self.get_json_rpc().create_notification(method, params); - - let message = format!( - "Content-Length: {}\r\n\r\n{}", - notification.len(), - notification - ); - debug!("Message: {:?}", message); - self.get_process().send(&message).await?; + self.get_process().send(¬ification).await?; Ok(()) } @@ -113,9 +106,7 @@ pub trait LspClient: Send { let mut response_receiver = self.get_pending_requests().add_request(id).await?; - let message = format!("Content-Length: {}\r\n\r\n{}", request.len(), request); - debug!("Message: {:?}", message); - self.get_process().send(&message).await?; + self.get_process().send(&request).await?; let response = response_receiver .recv() @@ -156,13 +147,7 @@ pub trait LspClient: Send { id, message ); let response = json_rpc.create_success_response(id); - - let message = format!( - "Content-Length: {}\r\n\r\n{}", - response.len(), - response - ); - let _ = process.send(&message).await; + let _ = process.send(&response).await; } } else if let Some(params) = message.params.clone() { let message_key = ExpectedMessageKey { diff --git a/crates/wrapper/src/lsp/process.rs b/crates/wrapper/src/lsp/process.rs index f82f5221..44877107 100644 --- a/crates/wrapper/src/lsp/process.rs +++ b/crates/wrapper/src/lsp/process.rs @@ -30,9 +30,11 @@ impl ProcessHandler { #[async_trait::async_trait] impl Process for ProcessHandler { - async fn send(&mut self, data: &str) -> Result<(), Box> { + async fn send(&mut self, content: &str) -> Result<(), Box> { + let message = format!("Content-Length: {}\r\n\r\n{}", content.len(), content); let mut stdin = self.stdin.lock().await; - stdin.write_all(data.as_bytes()).await?; + debug!("Sending content: {}", content); + stdin.write_all(message.as_bytes()).await?; stdin.flush().await?; Ok(()) } From 7aff8aef0de29c4bde8915f3fd10380c3f97d2da Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Mon, 24 Nov 2025 14:55:56 +0100 Subject: [PATCH 16/92] Log both send and receive --- crates/wrapper/src/lsp/client.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/wrapper/src/lsp/client.rs b/crates/wrapper/src/lsp/client.rs index e77b9c22..3b474289 100644 --- a/crates/wrapper/src/lsp/client.rs +++ b/crates/wrapper/src/lsp/client.rs @@ -93,6 +93,7 @@ pub trait LspClient: Send { params: Option, ) -> Result<(), Box> { let notification = self.get_json_rpc().create_notification(method, params); + debug!("Sending notification: {}", method); self.get_process().send(¬ification).await?; Ok(()) } @@ -106,6 +107,7 @@ pub trait LspClient: Send { let mut response_receiver = self.get_pending_requests().add_request(id).await?; + debug!("Sending request {}: {}", id, method); self.get_process().send(&request).await?; let response = response_receiver From e2a55b3ef785f93aaf547c3906bc9612e163cc21 Mon Sep 17 00:00:00 2001 From: Rick Winfrey Date: Mon, 24 Nov 2025 21:45:31 -0800 Subject: [PATCH 17/92] Give wrapper and proxy Dockerfiles separate cache ids --- dockerfiles/proxy.Dockerfile | 4 +++- dockerfiles/wrapper.Dockerfile | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/dockerfiles/proxy.Dockerfile b/dockerfiles/proxy.Dockerfile index 7f096a4b..159ec784 100644 --- a/dockerfiles/proxy.Dockerfile +++ b/dockerfiles/proxy.Dockerfile @@ -81,7 +81,9 @@ RUN mkdir -p crates/wrapper/src && \ # Build nuanced-lsp-proxy binary from workspace with cross-compilation support # Uses BuildKit cache mounts to cache Cargo registry and build artifacts across builds. # These caches persist even with --no-cache flag, significantly speeding up rebuilds. -RUN --mount=type=cache,target=/usr/local/cargo/registry,id=cargo-registry \ +# Note: Each image uses a distinct cache ID (cargo-registry-proxy, cargo-registry-wrapper) +# to prevent race conditions when building images in parallel. +RUN --mount=type=cache,target=/usr/local/cargo/registry,id=cargo-registry-proxy \ --mount=type=cache,target=/usr/src/target,id=cargo-target-proxy \ mkdir -p /usr/src/bin && \ case "$TARGETPLATFORM" in \ diff --git a/dockerfiles/wrapper.Dockerfile b/dockerfiles/wrapper.Dockerfile index 9fc587c3..56cd6c33 100644 --- a/dockerfiles/wrapper.Dockerfile +++ b/dockerfiles/wrapper.Dockerfile @@ -71,7 +71,9 @@ RUN mkdir -p crates/proxy/src && \ # Build nuanced-lsp-wrapper binary from workspace # Uses BuildKit cache mounts to cache Cargo registry and build artifacts across builds. # These caches persist even with --no-cache flag, significantly speeding up rebuilds. -RUN --mount=type=cache,target=/usr/local/cargo/registry,id=cargo-registry \ +# Note: Each image uses a distinct cache ID (cargo-registry-proxy, cargo-registry-wrapper) +# to prevent race conditions when building images in parallel. +RUN --mount=type=cache,target=/usr/local/cargo/registry,id=cargo-registry-wrapper \ --mount=type=cache,target=/usr/src/target,id=cargo-target-wrapper \ mkdir -p /usr/src/bin && \ case "$TARGETPLATFORM" in \ From e90fd4fc29f8ccc0b1463b3c5688fb8fdea80f6f Mon Sep 17 00:00:00 2001 From: Rick Winfrey Date: Mon, 24 Nov 2025 21:46:02 -0800 Subject: [PATCH 18/92] Use language container version default 1.0.0 for tests --- scripts/test.sh | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/test.sh b/scripts/test.sh index bb398aa5..4368fc6d 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -19,9 +19,10 @@ fi echo "1. Running Rust unit and integration tests..." echo "----------------------------------------" # Set Docker image versions for tests -# Use "latest" for development, or override with specific versions for production testing +# RUST_CONTAINER_VERSION: "latest" for locally-built images via build-rust-images.sh +# LANGUAGE_CONTAINER_VERSION: "1.0.0" to match published images on GHCR export RUST_CONTAINER_VERSION="${RUST_CONTAINER_VERSION:-latest}" -export LANGUAGE_CONTAINER_VERSION="${LANGUAGE_CONTAINER_VERSION:-latest}" +export LANGUAGE_CONTAINER_VERSION="${LANGUAGE_CONTAINER_VERSION:-1.0.0}" echo "Using image versions: Rust=${RUST_CONTAINER_VERSION}, Language=${LANGUAGE_CONTAINER_VERSION}" # Run with --test-threads=1 to ensure serial execution of integration tests # The container orchestration tests use #[serial] and a shared fixture From 36f4edf02acd97484585f1f35dbb1c8fde3fbf73 Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Tue, 25 Nov 2025 18:59:39 +0100 Subject: [PATCH 19/92] Replace lsproxy endpoints with custom LSP commands --- .../list_files.rs | 34 ++-- crates/proxy/src/custom_commands/mod.rs | 29 +++ .../src/custom_commands/read_source_code.rs | 106 +++++++++++ crates/proxy/src/handlers/container_proxy.rs | 2 +- .../proxy/src/handlers/definitions_in_file.rs | 51 ------ crates/proxy/src/handlers/find_definition.rs | 51 ------ crates/proxy/src/handlers/find_identifier.rs | 50 ----- .../src/handlers/find_referenced_symbols.rs | 57 ------ crates/proxy/src/handlers/find_references.rs | 57 ------ crates/proxy/src/handlers/lsp.rs | 7 +- crates/proxy/src/handlers/mod.rs | 12 +- crates/proxy/src/lib.rs | 57 +----- .../tests/container_orchestration_test.rs | 172 ++++++++++++------ .../custom_commands/definitions_in_file.rs | 67 +++++++ .../find_definition.rs | 80 ++++---- .../src/custom_commands/find_identifier.rs | 97 ++++++++++ .../find_referenced_symbols.rs | 79 ++++---- .../find_references.rs | 106 ++++++----- crates/wrapper/src/custom_commands/mod.rs | 48 +++++ .../src/handlers/definitions_in_file.rs | 57 ------ crates/wrapper/src/handlers/error.rs | 35 ---- .../wrapper/src/handlers/find_identifier.rs | 97 ---------- crates/wrapper/src/handlers/lsp.rs | 10 +- crates/wrapper/src/handlers/mod.rs | 7 - crates/wrapper/src/lib.rs | 1 + crates/wrapper/src/lsp/client.rs | 4 +- crates/wrapper/src/lsp/languages/generic.rs | 2 +- crates/wrapper/src/main.rs | 27 +-- crates/wrapper/src/manager.rs | 4 +- 29 files changed, 653 insertions(+), 753 deletions(-) rename crates/proxy/src/{handlers => custom_commands}/list_files.rs (69%) create mode 100644 crates/proxy/src/custom_commands/mod.rs create mode 100644 crates/proxy/src/custom_commands/read_source_code.rs delete mode 100644 crates/proxy/src/handlers/definitions_in_file.rs delete mode 100644 crates/proxy/src/handlers/find_definition.rs delete mode 100644 crates/proxy/src/handlers/find_identifier.rs delete mode 100644 crates/proxy/src/handlers/find_referenced_symbols.rs delete mode 100644 crates/proxy/src/handlers/find_references.rs create mode 100644 crates/wrapper/src/custom_commands/definitions_in_file.rs rename crates/wrapper/src/{handlers => custom_commands}/find_definition.rs (79%) create mode 100644 crates/wrapper/src/custom_commands/find_identifier.rs rename crates/wrapper/src/{handlers => custom_commands}/find_referenced_symbols.rs (85%) rename crates/wrapper/src/{handlers => custom_commands}/find_references.rs (71%) create mode 100644 crates/wrapper/src/custom_commands/mod.rs delete mode 100644 crates/wrapper/src/handlers/definitions_in_file.rs delete mode 100644 crates/wrapper/src/handlers/error.rs delete mode 100644 crates/wrapper/src/handlers/find_identifier.rs diff --git a/crates/proxy/src/handlers/list_files.rs b/crates/proxy/src/custom_commands/list_files.rs similarity index 69% rename from crates/proxy/src/handlers/list_files.rs rename to crates/proxy/src/custom_commands/list_files.rs index f5000335..3d701fcf 100644 --- a/crates/proxy/src/handlers/list_files.rs +++ b/crates/proxy/src/custom_commands/list_files.rs @@ -1,31 +1,23 @@ -use crate::AppState; -use actix_web::web::Data; use actix_web::HttpResponse; -use ignore::WalkBuilder; +use common::api_types::{get_mount_dir, JsonRpcRequest, JsonRpcResponse}; use log::{error, info}; -use std::path::Path; -use std::sync::{Arc, Mutex}; -/// List all files in the workspace -#[utoipa::path( - get, - path = "/workspace/list-files", - tag = "file", - responses( - (status = 200, description = "Files listed successfully"), - (status = 500, description = "Internal server error") - ) -)] -pub async fn list_files(data: Data) -> HttpResponse { +pub async fn handle(request: JsonRpcRequest) -> HttpResponse { + let req_id = request.id.clone(); + info!("Received list files request"); - let workspace_path = Path::new(&data.workspace_path); + // Get workspace path from manager's mount_dir + let workspace_path = get_mount_dir(); // Use parallel file walking for performance on large workspaces + use ignore::WalkBuilder; + use std::sync::{Arc, Mutex}; + let files = Arc::new(Mutex::new(Vec::new())); - let workspace_path_arc = Arc::new(workspace_path.to_path_buf()); + let workspace_path_arc = Arc::new(workspace_path.clone()); - let walker = WalkBuilder::new(workspace_path) + let walker = WalkBuilder::new(&workspace_path) .hidden(false) // Include hidden files (like .ruby-version, .python-version) .git_ignore(false) // Don't filter by gitignore - list all workspace files .git_exclude(false) // Don't use git exclude rules @@ -61,5 +53,7 @@ pub async fn list_files(data: Data) -> HttpResponse { files.sort(); files.dedup(); - HttpResponse::Ok().json(files) + let json_rpc_response = JsonRpcResponse::new_result(req_id, files); + + HttpResponse::Ok().json(json_rpc_response) } diff --git a/crates/proxy/src/custom_commands/mod.rs b/crates/proxy/src/custom_commands/mod.rs new file mode 100644 index 00000000..818bcdd7 --- /dev/null +++ b/crates/proxy/src/custom_commands/mod.rs @@ -0,0 +1,29 @@ +use actix_web::HttpResponse; +use common::api_types::JsonRpcRequest; +use log::info; + +mod list_files; +mod read_source_code; + +/// Dispatch custom lsproxy commands to their respective handlers +/// +/// Returns Some(response) if the command was handled, None if it should be forwarded to containers +pub async fn handle_custom_command(request: JsonRpcRequest) -> Option { + let method = &request.method; + + let response = match method.as_str() { + "lsproxy/workspace/listFiles" => { + info!("Handling custom command: {}", method); + list_files::handle(request).await + } + "lsproxy/workspace/readSourceCode" => { + info!("Handling custom command: {}", method); + read_source_code::handle(request).await + } + _ => { + return None; + } + }; + + Some(response) +} diff --git a/crates/proxy/src/custom_commands/read_source_code.rs b/crates/proxy/src/custom_commands/read_source_code.rs new file mode 100644 index 00000000..467cbe8c --- /dev/null +++ b/crates/proxy/src/custom_commands/read_source_code.rs @@ -0,0 +1,106 @@ +use actix_web::HttpResponse; +use common::api_types::{get_mount_dir, JsonRpcRequest, JsonRpcResponse, ReadSourceCodeRequest}; +use log::{error, info}; +use serde::Serialize; + +#[derive(Serialize)] +struct ReadSourceResponse { + source_code: String, +} + +pub async fn handle(request: JsonRpcRequest) -> HttpResponse { + let req_id = request.id.clone(); + + let params = match request.params.clone() { + Some(p) => p, + None => { + error!("Missing parameters for readSourceCode"); + let error = JsonRpcResponse::new_error(req_id, -32602, "Missing params"); + return HttpResponse::BadRequest().json(error); + } + }; + + let info: ReadSourceCodeRequest = match serde_json::from_value(params) { + Ok(info) => info, + Err(e) => { + error!("Invalid parameters for readSourceCode: {}", e); + let error = + JsonRpcResponse::new_error(req_id, -32602, format!("Invalid params: {}", e)); + return HttpResponse::BadRequest().json(error); + } + }; + + info!("Received read source code request for file: {}", info.path); + + // Build full path + let workspace_path = get_mount_dir(); + let file_path = workspace_path.join(&info.path); + + // Security check: ensure path is within workspace + let canonical_workspace = match std::fs::canonicalize(&workspace_path) { + Ok(p) => p, + Err(e) => { + error!("Failed to canonicalize workspace path: {}", e); + let error = + JsonRpcResponse::new_error(req_id, -32603, "Invalid workspace path".to_string()); + return HttpResponse::InternalServerError().json(error); + } + }; + + let canonical_file = match std::fs::canonicalize(&file_path) { + Ok(p) => p, + Err(e) => { + error!("File not found: {}", e); + let error = JsonRpcResponse::new_error( + req_id, + -32602, + format!("File not found: {}", info.path), + ); + return HttpResponse::NotFound().json(error); + } + }; + + if !canonical_file.starts_with(&canonical_workspace) { + error!("Path traversal attempt: {}", info.path); + let error = JsonRpcResponse::new_error(req_id, -32602, "Invalid file path".to_string()); + return HttpResponse::BadRequest().json(error); + } + + // Read the file content + match tokio::fs::read_to_string(&file_path).await { + Ok(content) => { + // If range is specified, return only that portion + let source_code = if let Some(range) = &info.range { + let lines: Vec<&str> = content.lines().collect(); + let start_line = range.start.line as usize; + let end_line = range.end.line as usize; + + if start_line >= lines.len() { + let error = JsonRpcResponse::new_error( + req_id, + -32602, + "Start line out of range".to_string(), + ); + return HttpResponse::BadRequest().json(error); + } + + let end_line = end_line.min(lines.len()); + let selected_lines = &lines[start_line..end_line]; + selected_lines.join("\n") + } else { + content + }; + + let response = ReadSourceResponse { source_code }; + let json_rpc_response = JsonRpcResponse::new_result(req_id, response); + + HttpResponse::Ok().json(json_rpc_response) + } + Err(e) => { + error!("Failed to read file: {}", e); + let error = + JsonRpcResponse::new_error(req_id, -32603, format!("Failed to read file: {}", e)); + HttpResponse::InternalServerError().json(error) + } + } +} diff --git a/crates/proxy/src/handlers/container_proxy.rs b/crates/proxy/src/handlers/container_proxy.rs index f098ac0e..733132b7 100644 --- a/crates/proxy/src/handlers/container_proxy.rs +++ b/crates/proxy/src/handlers/container_proxy.rs @@ -1,5 +1,4 @@ use crate::container::{ContainerHttpClient, ContainerOrchestrator}; -use log::{error, info}; /// Helper module for routing requests to containerized LSP servers /// /// This module handles: @@ -9,6 +8,7 @@ use log::{error, info}; /// - Returning responses use common::api_types::*; use common::utils::language_utils::detect_language; +use log::{error, info}; use std::sync::Arc; /// Get or spawn a container for the given language and return an HTTP client diff --git a/crates/proxy/src/handlers/definitions_in_file.rs b/crates/proxy/src/handlers/definitions_in_file.rs deleted file mode 100644 index e5f146ba..00000000 --- a/crates/proxy/src/handlers/definitions_in_file.rs +++ /dev/null @@ -1,51 +0,0 @@ -use crate::handlers::container_proxy; -use crate::AppState; -use actix_web::web::{Data, Query}; -use actix_web::HttpResponse; -use log::{error, info}; -use common::api_types::{ErrorResponse, FileSymbolsRequest, Symbol}; - -/// Get all symbol definitions in a file -#[utoipa::path( - get, - path = "/symbol/definitions-in-file", - tag = "symbol", - params(FileSymbolsRequest), - responses( - (status = 200, description = "Symbols retrieved successfully", body = Vec), - (status = 400, description = "Bad request"), - (status = 500, description = "Internal server error") - ) -)] -pub async fn definitions_in_file( - data: Data, - info: Query, -) -> HttpResponse { - info!( - "Received definitions in file request for file: {}", - info.file_path - ); - - // Get container client for this file's language - let client = - match container_proxy::get_client_for_file(&data.orchestrator, &info.file_path).await { - Ok(client) => client, - Err(e) => { - error!("Failed to get container client: {}", e); - return HttpResponse::InternalServerError().json(ErrorResponse { - error: format!("Failed to get container client: {}", e), - }); - } - }; - - // Forward request to container - match client.definitions_in_file(&info.into_inner()).await { - Ok(symbols) => HttpResponse::Ok().json(symbols), - Err(e) => { - error!("Container request failed: {}", e); - HttpResponse::InternalServerError().json(ErrorResponse { - error: format!("Container request failed: {}", e), - }) - } - } -} diff --git a/crates/proxy/src/handlers/find_definition.rs b/crates/proxy/src/handlers/find_definition.rs deleted file mode 100644 index 572a08de..00000000 --- a/crates/proxy/src/handlers/find_definition.rs +++ /dev/null @@ -1,51 +0,0 @@ -use crate::handlers::container_proxy; -use crate::AppState; -use actix_web::web::{Data, Json}; -use actix_web::HttpResponse; -use log::{error, info}; -use common::api_types::{DefinitionResponse, ErrorResponse, GetDefinitionRequest}; - -/// Get the definition of a symbol at a specific position in a file -#[utoipa::path( - post, - path = "/symbol/find-definition", - tag = "symbol", - request_body = GetDefinitionRequest, - responses( - (status = 200, description = "Definition retrieved successfully", body = DefinitionResponse), - (status = 400, description = "Bad request"), - (status = 500, description = "Internal server error") - ) -)] -pub async fn find_definition( - data: Data, - info: Json, -) -> HttpResponse { - info!( - "Received definition request for file: {}, line: {}, character: {}", - info.position.path, info.position.position.line, info.position.position.character - ); - - // Get container client for this file's language - let client = - match container_proxy::get_client_for_file(&data.orchestrator, &info.position.path).await { - Ok(client) => client, - Err(e) => { - error!("Failed to get container client: {}", e); - return HttpResponse::InternalServerError().json(ErrorResponse { - error: format!("Failed to get container client: {}", e), - }); - } - }; - - // Forward request to container - match client.find_definition(&info.into_inner()).await { - Ok(response) => HttpResponse::Ok().json(response), - Err(e) => { - error!("Container request failed: {}", e); - HttpResponse::InternalServerError().json(ErrorResponse { - error: format!("Container request failed: {}", e), - }) - } - } -} diff --git a/crates/proxy/src/handlers/find_identifier.rs b/crates/proxy/src/handlers/find_identifier.rs deleted file mode 100644 index dfaa05d6..00000000 --- a/crates/proxy/src/handlers/find_identifier.rs +++ /dev/null @@ -1,50 +0,0 @@ -use crate::handlers::container_proxy; -use crate::AppState; -use actix_web::web::{Data, Json}; -use actix_web::HttpResponse; -use log::{error, info}; -use common::api_types::{ErrorResponse, FindIdentifierRequest, IdentifierResponse}; - -/// Find an identifier by name -#[utoipa::path( - post, - path = "/symbol/find-identifier", - tag = "symbol", - request_body = FindIdentifierRequest, - responses( - (status = 200, description = "Identifier found successfully", body = IdentifierResponse), - (status = 400, description = "Bad request"), - (status = 500, description = "Internal server error") - ) -)] -pub async fn find_identifier( - data: Data, - info: Json, -) -> HttpResponse { - info!( - "Received find identifier request for file: {}, name: {}", - info.path, info.name - ); - - // Get container client for this file's language - let client = match container_proxy::get_client_for_file(&data.orchestrator, &info.path).await { - Ok(client) => client, - Err(e) => { - error!("Failed to get container client: {}", e); - return HttpResponse::InternalServerError().json(ErrorResponse { - error: format!("Failed to get container client: {}", e), - }); - } - }; - - // Forward request to container - match client.find_identifier(&info.into_inner()).await { - Ok(response) => HttpResponse::Ok().json(response), - Err(e) => { - error!("Container request failed: {}", e); - HttpResponse::InternalServerError().json(ErrorResponse { - error: format!("Container request failed: {}", e), - }) - } - } -} diff --git a/crates/proxy/src/handlers/find_referenced_symbols.rs b/crates/proxy/src/handlers/find_referenced_symbols.rs deleted file mode 100644 index dbf01cab..00000000 --- a/crates/proxy/src/handlers/find_referenced_symbols.rs +++ /dev/null @@ -1,57 +0,0 @@ -use crate::handlers::container_proxy; -use crate::AppState; -use actix_web::web::{Data, Json}; -use actix_web::HttpResponse; -use common::api_types::{ErrorResponse, GetReferencedSymbolsRequest, ReferencedSymbolsResponse}; -use log::{error, info}; - -/// Find all symbols referenced within a given symbol -#[utoipa::path( - post, - path = "/symbol/find-referenced-symbols", - tag = "symbol", - request_body = GetReferencedSymbolsRequest, - responses( - (status = 200, description = "Referenced symbols retrieved successfully", body = ReferencedSymbolsResponse), - (status = 400, description = "Bad request"), - (status = 500, description = "Internal server error") - ) -)] -pub async fn find_referenced_symbols( - data: Data, - info: Json, -) -> HttpResponse { - info!( - "Received find referenced symbols request for file: {}, line: {}, character: {}", - info.identifier_position.path, - info.identifier_position.position.line, - info.identifier_position.position.character - ); - - // Get container client for this file's language - let client = match container_proxy::get_client_for_file( - &data.orchestrator, - &info.identifier_position.path, - ) - .await - { - Ok(client) => client, - Err(e) => { - error!("Failed to get container client: {}", e); - return HttpResponse::InternalServerError().json(ErrorResponse { - error: format!("Failed to get container client: {}", e), - }); - } - }; - - // Forward request to container - match client.find_referenced_symbols(&info.into_inner()).await { - Ok(response) => HttpResponse::Ok().json(response), - Err(e) => { - error!("Container request failed: {}", e); - HttpResponse::InternalServerError().json(ErrorResponse { - error: format!("Container request failed: {}", e), - }) - } - } -} diff --git a/crates/proxy/src/handlers/find_references.rs b/crates/proxy/src/handlers/find_references.rs deleted file mode 100644 index 4e8f4b8a..00000000 --- a/crates/proxy/src/handlers/find_references.rs +++ /dev/null @@ -1,57 +0,0 @@ -use crate::handlers::container_proxy; -use crate::AppState; -use actix_web::web::{Data, Json}; -use actix_web::HttpResponse; -use log::{error, info}; -use common::api_types::{ErrorResponse, GetReferencesRequest, ReferencesResponse}; - -/// Get all references to a symbol -#[utoipa::path( - post, - path = "/symbol/find-references", - tag = "symbol", - request_body = GetReferencesRequest, - responses( - (status = 200, description = "References retrieved successfully", body = ReferencesResponse), - (status = 400, description = "Bad request"), - (status = 500, description = "Internal server error") - ) -)] -pub async fn find_references( - data: Data, - info: Json, -) -> HttpResponse { - info!( - "Received references request for file: {}, line: {}, character: {}", - info.identifier_position.path, - info.identifier_position.position.line, - info.identifier_position.position.character - ); - - // Get container client for this file's language - let client = match container_proxy::get_client_for_file( - &data.orchestrator, - &info.identifier_position.path, - ) - .await - { - Ok(client) => client, - Err(e) => { - error!("Failed to get container client: {}", e); - return HttpResponse::InternalServerError().json(ErrorResponse { - error: format!("Failed to get container client: {}", e), - }); - } - }; - - // Forward request to container - match client.find_references(&info.into_inner()).await { - Ok(response) => HttpResponse::Ok().json(response), - Err(e) => { - error!("Container request failed: {}", e); - HttpResponse::InternalServerError().json(ErrorResponse { - error: format!("Container request failed: {}", e), - }) - } - } -} diff --git a/crates/proxy/src/handlers/lsp.rs b/crates/proxy/src/handlers/lsp.rs index 28eb5c56..4000dc02 100644 --- a/crates/proxy/src/handlers/lsp.rs +++ b/crates/proxy/src/handlers/lsp.rs @@ -3,13 +3,13 @@ use crate::handlers::container_proxy; use crate::AppState; use actix_web::web::{Data, Json}; use actix_web::HttpResponse; +use common::api_types::{JsonRpcRequest, JsonRpcResponse}; use log::{debug, error, info, warn}; use lsp_types::{ DeclarationCapability, FoldingRangeProviderCapability, HoverProviderCapability, ImplementationProviderCapability, InitializeResult, OneOf, PositionEncodingKind, ServerCapabilities, ServerInfo, TextDocumentSyncKind, TextDocumentSyncOptions, }; -use common::api_types::{JsonRpcRequest, JsonRpcResponse}; use serde_json::Value; use std::sync::Arc; use url::Url; @@ -39,6 +39,11 @@ pub async fn lsp(data: Data, request: Json) -> HttpRes return response; } + // Try to handle as a custom command first + if let Some(response) = crate::custom_commands::handle_custom_command(lsp_req.clone()).await { + return response; + } + // For language feature requests, extract document URI and route to appropriate backend let document_uri = match extract_document_uri(lsp_req.params.as_ref()) { Some(uri) => uri, diff --git a/crates/proxy/src/handlers/mod.rs b/crates/proxy/src/handlers/mod.rs index eb255cdc..e6ade8db 100644 --- a/crates/proxy/src/handlers/mod.rs +++ b/crates/proxy/src/handlers/mod.rs @@ -1,15 +1,5 @@ -mod definitions_in_file; -mod find_definition; -mod find_identifier; -mod find_referenced_symbols; -mod find_references; mod health; -mod list_files; mod lsp; -mod read_source_code; pub(crate) mod container_proxy; -pub use self::{ - definitions_in_file::*, find_definition::*, find_identifier::*, find_referenced_symbols::*, - find_references::*, health::*, list_files::*, lsp::*, read_source_code::*, -}; +pub use self::{health::*, lsp::*}; diff --git a/crates/proxy/src/lib.rs b/crates/proxy/src/lib.rs index 70d945db..5487990e 100644 --- a/crates/proxy/src/lib.rs +++ b/crates/proxy/src/lib.rs @@ -4,9 +4,7 @@ use actix_web::{ web::{get, post, resource, scope, Data}, App, HttpServer, }; -use handlers::{find_identifier, read_source_code}; use log::{error, info, warn}; -use common::api_types::{FindIdentifierRequest, IdentifierResponse}; use middleware::JwtMiddleware; use std::fs; use std::fs::File; @@ -19,18 +17,13 @@ use utoipa_swagger_ui::SwaggerUi; // Local modules pub mod container; +mod custom_commands; mod handlers; -use crate::handlers::{ - definitions_in_file, find_definition, find_referenced_symbols, find_references, health_check, - list_files, lsp, -}; +use crate::handlers::{health_check, lsp}; use common::api_types::{ - get_mount_dir, set_global_mount_dir, CodeContext, DefinitionResponse, ErrorResponse, - FilePosition, FileRange, FileSymbolsRequest, GetDefinitionRequest, GetReferencedSymbolsRequest, - GetReferencesRequest, HealthResponse, JsonRpcError, JsonRpcRequest, JsonRpcResponse, Position, - ReferenceWithSymbolDefinitions, ReferencedSymbolsResponse, ReferencesResponse, - SupportedLanguages, Symbol, SymbolResponse, + get_mount_dir, set_global_mount_dir, HealthResponse, JsonRpcError, JsonRpcRequest, + JsonRpcResponse, }; // use common::utils::doc_utils::make_code_sample; @@ -54,52 +47,28 @@ pub fn check_mount_dir() -> std::io::Result<()> { ), components( schemas( - FileSymbolsRequest, - GetDefinitionRequest, - GetReferencesRequest, - GetReferencedSymbolsRequest, - SupportedLanguages, - DefinitionResponse, - ReferencesResponse, - ReferencedSymbolsResponse, - SymbolResponse, - ReferenceWithSymbolDefinitions, - FilePosition, - Position, - Symbol, - ErrorResponse, - CodeContext, - FileRange, HealthResponse, - FindIdentifierRequest, - IdentifierResponse, JsonRpcRequest, JsonRpcResponse, JsonRpcError, ) ), paths( - crate::handlers::definitions_in_file, - crate::handlers::find_definition, - crate::handlers::find_references, crate::handlers::health_check, - crate::handlers::list_files, - crate::handlers::read_source_code, - crate::handlers::find_referenced_symbols, - crate::handlers::find_identifier, crate::handlers::lsp, ), tags( (name = "nuanced-lsp-api", description = "Nuanced LSP API") ), servers( - (url = "http://localhost:4444/v1", description = "API server v1") + (url = "http://localhost:4444/v2", description = "API server v2") ) )] pub struct ApiDoc; pub struct AppState { orchestrator: Arc, + #[allow(dead_code)] workspace_path: String, initialization_complete: Arc, } @@ -263,20 +232,6 @@ pub async fn run_server_with_port_and_host( }; api_scope = match (path.as_str(), method) { - ("/symbol/find-definition", Some(Method::Post)) => - api_scope.service(resource(path).route(post().to(find_definition))), - ("/symbol/find-references", Some(Method::Post)) => - api_scope.service(resource(path).route(post().to(find_references))), - ("/symbol/find-referenced-symbols", Some(Method::Post)) => - api_scope.service(resource(path).route(post().to(find_referenced_symbols))), - ("/symbol/find-identifier", Some(Method::Post)) => - api_scope.service(resource(path).route(post().to(find_identifier))), - ("/symbol/definitions-in-file", Some(Method::Get)) => - api_scope.service(resource(path).route(get().to(definitions_in_file))), - ("/workspace/list-files", Some(Method::Get)) => - api_scope.service(resource(path).route(get().to(list_files))), - ("/workspace/read-source-code", Some(Method::Post)) => - api_scope.service(resource(path).route(post().to(read_source_code))), ("/system/health", Some(Method::Get)) => api_scope.service(resource(path).route(get().to(health_check))), ("/lsp", Some(Method::Post)) => diff --git a/crates/proxy/tests/container_orchestration_test.rs b/crates/proxy/tests/container_orchestration_test.rs index 68b47bd1..68412f38 100644 --- a/crates/proxy/tests/container_orchestration_test.rs +++ b/crates/proxy/tests/container_orchestration_test.rs @@ -21,8 +21,8 @@ use tempfile::TempDir; use tokio::sync::Mutex; use tokio::time::sleep; -use proxy::container::{language_image_ghcr, proxy_image, WRAPPER_IMAGE_BASE}; use common::api_types::SupportedLanguages; +use proxy::container::{language_image_ghcr, proxy_image, WRAPPER_IMAGE_BASE}; // Helper function for Python test image (GHCR name) fn python_image() -> String { @@ -271,7 +271,7 @@ impl ContainerFixture { async fn wait_for_health_static() -> Result<(), Box> { let client = Client::builder().timeout(Duration::from_secs(5)).build()?; - let health_url = format!("{}/v1/system/health", BASE_URL); + let health_url = format!("{}/v2/system/health", BASE_URL); for attempt in 1..=MAX_RETRIES { match client.get(&health_url).send().await { @@ -324,7 +324,7 @@ async fn wait_for_language_health(lang_key: &str) -> Result<(), Box Result<(), Box> { let client = Client::new(); let response = client - .get(&format!("{}/v1/system/health", BASE_URL)) + .get(&format!("{}/v2/system/health", BASE_URL)) .send() .await?; @@ -414,14 +414,19 @@ async fn test_container_spawn_on_request() -> Result<(), Box Result<(), Box> { // Test find-definition endpoint let response = client - .post(&format!("{}/v1/symbol/find-definition", BASE_URL)) + .post(&format!("{}/v2/lsp", BASE_URL)) .json(&json!({ - "position": { - "path": "test.py", - "position": {"line": 0, "character": 4} - }, - "include_source_code": false, - "include_raw_response": false + "jsonrpc": "2.0", + "id": 1, + "method": "lsproxy/symbol/findDefinition", + "params": { + "position": { + "path": "test.py", + "position": {"line": 0, "character": 4} + }, + "include_source_code": false, + "include_raw_response": false + } })) .send() .await?; @@ -473,8 +483,11 @@ async fn test_request_forwarding() -> Result<(), Box> { assert!(response.status().is_success()); let body: serde_json::Value = response.json().await?; - // Should have definitions field (even if empty) - assert!(body.get("definitions").is_some()); + // Should have result.definitions field (even if empty) + assert!(body + .get("result") + .and_then(|r| r.get("definitions")) + .is_some()); Ok(()) } @@ -491,14 +504,19 @@ async fn test_multiple_requests_same_container() -> Result<(), Box Result<(), Box Result<(), Box> { let client = Client::new(); let response = client - .get(&format!("{}/v1/workspace/list-files", BASE_URL)) + .post(&format!("{}/v2/lsp", BASE_URL)) + .json(&json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "lsproxy/workspace/listFiles", + "params": {} + })) .send() .await?; assert!(response.status().is_success()); let body: serde_json::Value = response.json().await?; - // The endpoint returns a direct array of filenames, not an object with a "files" field - assert!(body.is_array()); + // The result should be a direct array of filenames + assert!(body.get("result").is_some()); + assert!(body.get("result").unwrap().is_array()); Ok(()) } @@ -587,13 +617,18 @@ async fn test_find_references() -> Result<(), Box> { wait_for_language_health("python").await?; let response = client - .post(&format!("{}/v1/symbol/find-references", BASE_URL)) + .post(&format!("{}/v2/lsp", BASE_URL)) .json(&json!({ - "identifier_position": { - "path": "test.py", - "position": {"line": 0, "character": 4} - }, - "include_code_context_lines": 0 + "jsonrpc": "2.0", + "id": 1, + "method": "lsproxy/symbol/findReferences", + "params": { + "identifier_position": { + "path": "test.py", + "position": {"line": 0, "character": 4} + }, + "include_code_context_lines": 0 + } })) .send() .await?; @@ -601,8 +636,11 @@ async fn test_find_references() -> Result<(), Box> { assert!(response.status().is_success()); let body: serde_json::Value = response.json().await?; - // Should have references field - assert!(body.get("references").is_some()); + // Should have result.references field + assert!(body + .get("result") + .and_then(|r| r.get("references")) + .is_some()); Ok(()) } @@ -621,13 +659,18 @@ async fn test_find_references_with_context_lines() -> Result<(), Box Result<(), Box 0, "Should find at least one reference to 'hello'" @@ -666,13 +710,18 @@ async fn test_find_references_with_context_lines() -> Result<(), Box Result<(), Box HttpResponse { + let req_id = request.id.clone(); + + let params = match request.params.clone() { + Some(p) => p, + None => { + error!("Missing parameters for definitionsInFile"); + let error = JsonRpcResponse::new_error(req_id, -32602, "Missing params"); + return HttpResponse::BadRequest().json(error); + } + }; + + let info: FileSymbolsRequest = match serde_json::from_value(params) { + Ok(info) => info, + Err(e) => { + error!("Invalid parameters for definitionsInFile: {}", e); + let error = + JsonRpcResponse::new_error(req_id, -32602, format!("Invalid params: {}", e)); + return HttpResponse::BadRequest().json(error); + } + }; + + info!( + "Received definitions in file request for file: {}", + info.file_path + ); + + match manager.get_definitions_in_file(&info.file_path).await { + Ok(symbols) => { + let symbol_response: Vec = symbols + .into_iter() + .filter(|s| s.rule_id != "local-variable") + .map(Symbol::from) + .collect(); + + let json_rpc_response = JsonRpcResponse::new_result(req_id, symbol_response); + + HttpResponse::Ok().json(json_rpc_response) + } + Err(e) => { + let error = + JsonRpcResponse::new_error(req_id, -32603, format!("Couldn't get symbols: {}", e)); + HttpResponse::BadRequest().json(error) + } + } +} diff --git a/crates/wrapper/src/handlers/find_definition.rs b/crates/wrapper/src/custom_commands/find_definition.rs similarity index 79% rename from crates/wrapper/src/handlers/find_definition.rs rename to crates/wrapper/src/custom_commands/find_definition.rs index cdc6e204..6c948ba0 100644 --- a/crates/wrapper/src/handlers/find_definition.rs +++ b/crates/wrapper/src/custom_commands/find_definition.rs @@ -1,16 +1,14 @@ -use crate::handlers::error::IntoHttpResponse; +use crate::handlers::utils; use crate::manager::{LspManagerError, Manager}; -use actix_web::web::{Data, Json}; use actix_web::HttpResponse; -use log::{error, info, warn}; -use common::api_types::{CodeContext, FileRange, Position, Range}; +use common::api_types::{ + CodeContext, DefinitionResponse, FilePosition, FileRange, GetDefinitionRequest, JsonRpcRequest, + JsonRpcResponse, Position, Range, +}; use common::utils::file_utils::uri_to_relative_path_string; - -use crate::AppState; +use log::{error, info, warn}; use lsp_types::{GotoDefinitionResponse, Location, Position as LspPosition, Range as LspRange}; -use common::api_types::{DefinitionResponse, GetDefinitionRequest}; -use crate::handlers::utils; -use common::api_types::{ErrorResponse, FilePosition}; + /// Get the definition of a symbol at a specific position in a file /// /// Returns the location of the definition for the symbol at the given position. @@ -30,21 +28,28 @@ use common::api_types::{ErrorResponse, FilePosition}; /// 5: user = User("John", 30) /// input_____^^^^ /// ``` -#[utoipa::path( - post, - path = "/symbol/find-definition", - tag = "symbol", - request_body = GetDefinitionRequest, - responses( - (status = 200, description = "Definition retrieved successfully", body = DefinitionResponse), - (status = 400, description = "Bad request"), - (status = 500, description = "Internal server error") - ) -)] -pub async fn find_definition( - data: Data, - info: Json, -) -> HttpResponse { +pub async fn handle(manager: &Manager, request: JsonRpcRequest) -> HttpResponse { + let req_id = request.id.clone(); + + let params = match request.params.clone() { + Some(p) => p, + None => { + error!("Missing parameters for findDefinition"); + let error = JsonRpcResponse::new_error(req_id, -32602, "Missing params"); + return HttpResponse::BadRequest().json(error); + } + }; + + let info: GetDefinitionRequest = match serde_json::from_value(params) { + Ok(info) => info, + Err(e) => { + error!("Invalid parameters for findDefinition: {}", e); + let error = + JsonRpcResponse::new_error(req_id, -32602, format!("Invalid params: {}", e)); + return HttpResponse::BadRequest().json(error); + } + }; + info!( "Received definition request for file: {}, line: {}, character: {}", info.position.path, info.position.position.line, info.position.position.character @@ -56,13 +61,16 @@ pub async fn find_definition( position: info.position.position.clone(), }; - let file_identifiers = match data.manager.get_file_identifiers(&file_position.path).await { + let file_identifiers = match manager.get_file_identifiers(&file_position.path).await { Ok(identifiers) => identifiers, Err(e) => { error!("Failed to get file identifiers: {:?}", e); - return HttpResponse::InternalServerError().json(ErrorResponse { - error: format!("Failed to get file identifiers: {}", e), - }); + let error = JsonRpcResponse::new_error( + req_id, + -32603, + format!("Failed to get file identifiers: {}", e), + ); + return HttpResponse::InternalServerError().json(error); } }; @@ -76,8 +84,7 @@ pub async fn find_definition( }; // Call LSP directly (no ast-grep for identifier detection) - let definitions = match data - .manager + let definitions = match manager .find_definition( &info.position.path, LspPosition { @@ -89,12 +96,13 @@ pub async fn find_definition( { Ok(definitions) => definitions, Err(e) => { - return e.into_http_response(); + let error = JsonRpcResponse::new_error(req_id, -32603, format!("LSP error: {}", e)); + return HttpResponse::InternalServerError().json(error); } }; let source_code_context = if info.include_source_code { - match fetch_definition_source_code(&data.manager, &definitions).await { + match fetch_definition_source_code(manager, &definitions).await { Ok(context) => Some(context), Err(e) => { error!("Failed to fetch definition source code: {:?}", e); @@ -105,7 +113,7 @@ pub async fn find_definition( None }; - HttpResponse::Ok().json(DefinitionResponse { + let response = DefinitionResponse { raw_response: if info.include_raw_response { Some(serde_json::to_value(&definitions).unwrap()) } else { @@ -130,7 +138,11 @@ pub async fn find_definition( }, }, }), - }) + }; + + let json_rpc_response = JsonRpcResponse::new_result(req_id, response); + + HttpResponse::Ok().json(json_rpc_response) } async fn fetch_definition_source_code( diff --git a/crates/wrapper/src/custom_commands/find_identifier.rs b/crates/wrapper/src/custom_commands/find_identifier.rs new file mode 100644 index 00000000..c358aefe --- /dev/null +++ b/crates/wrapper/src/custom_commands/find_identifier.rs @@ -0,0 +1,97 @@ +use crate::handlers::utils; +use crate::manager::Manager; +use actix_web::HttpResponse; +use common::api_types::{ + FilePosition, FindIdentifierRequest, Identifier, IdentifierResponse, JsonRpcRequest, + JsonRpcResponse, +}; +use log::{error, info}; + +/// Find an identifier by name +pub async fn handle(manager: &Manager, request: JsonRpcRequest) -> HttpResponse { + let req_id = request.id.clone(); + + let params = match request.params.clone() { + Some(p) => p, + None => { + error!("Missing parameters for findIdentifier"); + let error = JsonRpcResponse::new_error(req_id, -32602, "Missing params"); + return HttpResponse::BadRequest().json(error); + } + }; + + let info: FindIdentifierRequest = match serde_json::from_value(params) { + Ok(info) => info, + Err(e) => { + error!("Invalid parameters for findIdentifier: {}", e); + let error = + JsonRpcResponse::new_error(req_id, -32602, format!("Invalid params: {}", e)); + return HttpResponse::BadRequest().json(error); + } + }; + + info!( + "Received identifier request for file: {}, name: {}, position: {:?}", + info.path, info.name, info.position + ); + let file_identifiers = match manager.get_file_identifiers(&info.path).await { + Ok(identifiers) => identifiers, + Err(e) => { + error!("Failed to get file identifiers: {:?}", e); + let error = JsonRpcResponse::new_error( + req_id, + -32603, + format!("Failed to get file identifiers: {}", e), + ); + return HttpResponse::InternalServerError().json(error); + } + }; + + // filter identifiers by name + let name_matched_identifiers: Vec = file_identifiers + .into_iter() + .filter(|id| id.name == info.name) + .collect(); + + if name_matched_identifiers.is_empty() { + let response = IdentifierResponse { + identifiers: vec![], + }; + let json_rpc_response = JsonRpcResponse::new_result(req_id, response); + return HttpResponse::Ok().json(json_rpc_response); + } + + if let Some(position) = &info.position { + match utils::find_identifier_at_position( + name_matched_identifiers.clone(), + &FilePosition { + path: info.path.clone(), + position: position.clone(), + }, + ) + .await + { + Ok(identifier) => { + let response = IdentifierResponse { + identifiers: vec![identifier], + }; + let json_rpc_response = JsonRpcResponse::new_result(req_id, response); + HttpResponse::Ok().json(json_rpc_response) + } + Err(utils::PositionError::IdentifierNotFound { closest }) => { + // Not an error case, just closest matches + let response = IdentifierResponse { + identifiers: closest, + }; + let json_rpc_response = JsonRpcResponse::new_result(req_id, response); + HttpResponse::Ok().json(json_rpc_response) + } + } + } else { + let response = IdentifierResponse { + identifiers: name_matched_identifiers, + }; + let json_rpc_response = JsonRpcResponse::new_result(req_id, response); + HttpResponse::Ok().json(json_rpc_response) + } +} diff --git a/crates/wrapper/src/handlers/find_referenced_symbols.rs b/crates/wrapper/src/custom_commands/find_referenced_symbols.rs similarity index 85% rename from crates/wrapper/src/handlers/find_referenced_symbols.rs rename to crates/wrapper/src/custom_commands/find_referenced_symbols.rs index ee49d082..ceacb2e6 100644 --- a/crates/wrapper/src/handlers/find_referenced_symbols.rs +++ b/crates/wrapper/src/custom_commands/find_referenced_symbols.rs @@ -1,13 +1,12 @@ -use crate::AppState; -use actix_web::web::{Data, Json}; +use crate::manager::Manager; use actix_web::HttpResponse; -use log::{error, info}; -use lsp_types::{GotoDefinitionResponse, Position as LspPosition}; use common::api_types::{ - get_mount_dir, ErrorResponse, FilePosition, GetReferencedSymbolsRequest, Identifier, Position, - ReferenceWithSymbolDefinitions, ReferencedSymbolsResponse, + get_mount_dir, FilePosition, GetReferencedSymbolsRequest, Identifier, JsonRpcRequest, + JsonRpcResponse, Position, ReferenceWithSymbolDefinitions, ReferencedSymbolsResponse, }; use common::utils::file_utils::uri_to_relative_path_string; +use log::{error, info}; +use lsp_types::{GotoDefinitionResponse, Position as LspPosition}; /// Find all symbols that are referenced from a given symbol's definition /// @@ -30,21 +29,28 @@ use common::utils::file_utils::uri_to_relative_path_string; /// User (with definition from models.py) /// ] /// - External symbols: print (Python built-in) -#[utoipa::path( - post, - path = "/symbol/find-referenced-symbols", - tag = "symbol", - request_body = GetReferencedSymbolsRequest, - responses( - (status = 200, description = "Referenced symbols retrieved successfully", body = ReferencedSymbolsResponse), - (status = 400, description = "Bad request"), - (status = 500, description = "Internal server error") - ) -)] -pub async fn find_referenced_symbols( - data: Data, - info: Json, -) -> HttpResponse { +pub async fn handle(manager: &Manager, request: JsonRpcRequest) -> HttpResponse { + let req_id = request.id.clone(); + + let params = match request.params.clone() { + Some(p) => p, + None => { + error!("Missing parameters for findReferencedSymbols"); + let error = JsonRpcResponse::new_error(req_id, -32602, "Missing params"); + return HttpResponse::BadRequest().json(error); + } + }; + + let info: GetReferencedSymbolsRequest = match serde_json::from_value(params) { + Ok(info) => info, + Err(e) => { + error!("Invalid parameters for findReferencedSymbols: {}", e); + let error = + JsonRpcResponse::new_error(req_id, -32602, format!("Invalid params: {}", e)); + return HttpResponse::BadRequest().json(error); + } + }; + info!( "Received referenced symbols request for file: {}, line: {}, character: {}", info.identifier_position.path, @@ -52,8 +58,7 @@ pub async fn find_referenced_symbols( info.identifier_position.position.character ); - let referenecd_ast_symbols = match data - .manager + let referenced_ast_symbols = match manager .find_referenced_symbols( &info.identifier_position.path, LspPosition { @@ -67,14 +72,17 @@ pub async fn find_referenced_symbols( Ok(ast_symbols) => ast_symbols, Err(e) => { error!("Failed to get referenced symbols: {:?}", e); - return HttpResponse::InternalServerError().json(ErrorResponse { - error: format!("Failed to get referenced symbols: {}", e), - }); + let error = JsonRpcResponse::new_error( + req_id, + -32603, + format!("Failed to get referenced symbols: {}", e), + ); + return HttpResponse::InternalServerError().json(error); } }; let unwrapped_definition_responses: Vec<(Identifier, Vec)> = - referenecd_ast_symbols + referenced_ast_symbols .into_iter() .map(|(ast_grep_result, definition_response)| { let definitions = match definition_response { @@ -135,8 +143,7 @@ pub async fn find_referenced_symbols( character: def.position.character, }; - match data - .manager + match manager .get_symbol_from_position(&def.path, &def_position) .await { @@ -163,7 +170,7 @@ pub async fn find_referenced_symbols( // Solution: Use ast-grep to get all identifiers in the file, find the one // matching by name and line number, then call get_symbol_from_position // using that identifier's position (which aligns with documentSymbol). - match data.manager.get_file_identifiers(&def.path).await { + match manager.get_file_identifiers(&def.path).await { Ok(identifiers) => { // Find the identifier on the same line as the definition with matching name if let Some(found_identifier) = identifiers.iter().find(|id| { @@ -180,8 +187,7 @@ pub async fn find_referenced_symbols( .character, }; - if let Ok(symbol) = data - .manager + if let Ok(symbol) = manager .get_symbol_from_position(&def.path, &id_position) .await { @@ -259,10 +265,13 @@ pub async fn find_referenced_symbols( } }); - // Return the sorted response - HttpResponse::Ok().json(ReferencedSymbolsResponse { + let response = ReferencedSymbolsResponse { workspace_symbols, external_symbols, not_found, - }) + }; + + let json_rpc_response = JsonRpcResponse::new_result(req_id, response); + + HttpResponse::Ok().json(json_rpc_response) } diff --git a/crates/wrapper/src/handlers/find_references.rs b/crates/wrapper/src/custom_commands/find_references.rs similarity index 71% rename from crates/wrapper/src/handlers/find_references.rs rename to crates/wrapper/src/custom_commands/find_references.rs index 74dfb0e3..e273a297 100644 --- a/crates/wrapper/src/handlers/find_references.rs +++ b/crates/wrapper/src/custom_commands/find_references.rs @@ -1,17 +1,13 @@ -use actix_web::web::{Data, Json}; -use actix_web::HttpResponse; -use log::{error, info}; -use lsp_types::{Location, Position as LspPosition}; - -use crate::handlers::error::IntoHttpResponse; use crate::handlers::utils; use crate::manager::{LspManagerError, Manager}; -use crate::AppState; +use actix_web::HttpResponse; use common::api_types::{ - get_mount_dir, CodeContext, ErrorResponse, FilePosition, FileRange, GetReferencesRequest, - Position, Range, ReferencesResponse, + get_mount_dir, CodeContext, FilePosition, FileRange, GetReferencesRequest, JsonRpcRequest, + JsonRpcResponse, Position, Range, ReferencesResponse, }; use common::utils::file_utils::uri_to_relative_path_string; +use log::{error, info}; +use lsp_types::{Location, Position as LspPosition}; /// Find all references to a symbol /// @@ -32,21 +28,28 @@ use common::utils::file_utils::uri_to_relative_path_string; /// 5: user = User("John", 30) /// output____^ /// ``` -#[utoipa::path( - post, - path = "/symbol/find-references", - tag = "symbol", - request_body = GetReferencesRequest, - responses( - (status = 200, description = "References retrieved successfully", body = ReferencesResponse), - (status = 400, description = "Bad request"), - (status = 500, description = "Internal server error") - ) -)] -pub async fn find_references( - data: Data, - info: Json, -) -> HttpResponse { +pub async fn handle(manager: &Manager, request: JsonRpcRequest) -> HttpResponse { + let req_id = request.id.clone(); + + let params = match request.params.clone() { + Some(p) => p, + None => { + error!("Missing parameters for findReferences"); + let error = JsonRpcResponse::new_error(req_id, -32602, "Missing params"); + return HttpResponse::BadRequest().json(error); + } + }; + + let info: GetReferencesRequest = match serde_json::from_value(params) { + Ok(info) => info, + Err(e) => { + error!("Invalid parameters for findReferences: {}", e); + let error = + JsonRpcResponse::new_error(req_id, -32602, format!("Invalid params: {}", e)); + return HttpResponse::BadRequest().json(error); + } + }; + info!( "Received references request for file: {}, line: {}, character: {}", info.identifier_position.path, @@ -54,17 +57,19 @@ pub async fn find_references( info.identifier_position.position.character ); - let file_identifiers = match data - .manager + let file_identifiers = match manager .get_file_identifiers(&info.identifier_position.path) .await { Ok(identifiers) => identifiers, Err(e) => { error!("Failed to get file identifiers: {:?}", e); - return HttpResponse::InternalServerError().json(ErrorResponse { - error: format!("Failed to get file identifiers: {}", e), - }); + let error = JsonRpcResponse::new_error( + req_id, + -32603, + format!("Failed to get file identifiers: {}", e), + ); + return HttpResponse::InternalServerError().json(error); } }; @@ -74,20 +79,18 @@ pub async fn find_references( Ok(identifier) => identifier, Err(e) => { error!("Failed to find references from position: {:?}", e); - return HttpResponse::BadRequest().json(ErrorResponse { - error: format!("Failed to find references from position: {}", e), - }); + let error = JsonRpcResponse::new_error( + req_id, + -32602, + format!("Failed to find references from position: {}", e), + ); + return HttpResponse::BadRequest().json(error); } }; - let references_result = - find_and_filter_references(&data.manager, &info.identifier_position).await; - let code_contexts_result = get_code_contexts( - &data.manager, - &references_result, - info.include_code_context_lines, - ) - .await; + let references_result = find_and_filter_references(manager, &info.identifier_position).await; + let code_contexts_result = + get_code_contexts(manager, &references_result, info.include_code_context_lines).await; match (references_result, code_contexts_result) { (Ok(references), Ok(code_contexts)) => { @@ -118,14 +121,23 @@ pub async fn find_references( context: code_contexts, selected_identifier, }; - HttpResponse::Ok().json(response) + + let json_rpc_response = JsonRpcResponse::new_result(req_id, response); + + HttpResponse::Ok().json(json_rpc_response) + } + (Err(e), _) => { + let error = JsonRpcResponse::new_error(req_id, -32603, format!("LSP error: {}", e)); + HttpResponse::InternalServerError().json(error) } - (Err(e), _) => handle_lsp_error(e), (_, Err(e)) => { error!("Failed to fetch code context: {}", e); - HttpResponse::InternalServerError().json(ErrorResponse { - error: format!("Failed to fetch code context: {}", e), - }) + let error = JsonRpcResponse::new_error( + req_id, + -32603, + format!("Failed to fetch code context: {}", e), + ); + HttpResponse::InternalServerError().json(error) } } } @@ -178,10 +190,6 @@ async fn get_code_contexts( } } -fn handle_lsp_error(e: LspManagerError) -> HttpResponse { - e.into_http_response() -} - async fn fetch_code_context( manager: &Manager, references: Vec, diff --git a/crates/wrapper/src/custom_commands/mod.rs b/crates/wrapper/src/custom_commands/mod.rs new file mode 100644 index 00000000..3df8a2ff --- /dev/null +++ b/crates/wrapper/src/custom_commands/mod.rs @@ -0,0 +1,48 @@ +use crate::manager::Manager; +use actix_web::HttpResponse; +use common::api_types::JsonRpcRequest; +use log::info; + +mod definitions_in_file; +mod find_definition; +mod find_identifier; +mod find_referenced_symbols; +mod find_references; + +/// Dispatch custom lsproxy commands to their respective handlers +/// +/// Returns Some(response) if the command was handled, None if it should be forwarded to LSP +pub async fn handle_custom_command( + manager: &Manager, + request: JsonRpcRequest, +) -> Option { + let method = &request.method; + + let response = match method.as_str() { + "lsproxy/symbol/findDefinition" => { + info!("Handling custom command: {}", method); + find_definition::handle(manager, request).await + } + "lsproxy/symbol/findReferences" => { + info!("Handling custom command: {}", method); + find_references::handle(manager, request).await + } + "lsproxy/symbol/definitionsInFile" => { + info!("Handling custom command: {}", method); + definitions_in_file::handle(manager, request).await + } + "lsproxy/symbol/findIdentifier" => { + info!("Handling custom command: {}", method); + find_identifier::handle(manager, request).await + } + "lsproxy/symbol/findReferencedSymbols" => { + info!("Handling custom command: {}", method); + find_referenced_symbols::handle(manager, request).await + } + _ => { + return None; + } + }; + + Some(response) +} diff --git a/crates/wrapper/src/handlers/definitions_in_file.rs b/crates/wrapper/src/handlers/definitions_in_file.rs deleted file mode 100644 index e9108b70..00000000 --- a/crates/wrapper/src/handlers/definitions_in_file.rs +++ /dev/null @@ -1,57 +0,0 @@ -use actix_web::web::{Data, Query}; -use actix_web::HttpResponse; -use log::info; - -use crate::AppState; -use common::api_types::{ErrorResponse, FileSymbolsRequest, Symbol}; - -/// Get symbols in a specific file (uses ast-grep) -/// -/// Returns a list of symbols (functions, classes, variables, etc.) defined in the specified file. -/// -/// Only the variabels defined at the file level are included. -/// -/// The returned positions point to the start of the symbol's identifier. -/// -/// e.g. for `User` on line 0 of `src/main.py`: -/// ```text -/// 0: class User: -/// _________^ -/// 1: def __init__(self, name, age): -/// 2: self.name = name -/// 3: self.age = age -/// ``` -#[utoipa::path( - get, - path = "/symbol/definitions-in-file", - tag = "symbol", - params(FileSymbolsRequest), - responses( - (status = 200, description = "Symbols retrieved successfully", body = Vec), - (status = 400, description = "Bad request"), - (status = 500, description = "Internal server error") - ) -)] -pub async fn definitions_in_file( - data: Data, - info: Query, -) -> HttpResponse { - info!( - "Received definitions in file request for file: {}", - info.file_path - ); - - match data.manager.get_definitions_in_file(&info.file_path).await { - Ok(symbols) => { - let symbol_response: Vec = symbols - .into_iter() - .filter(|s| s.rule_id != "local-variable") - .map(Symbol::from) - .collect(); - HttpResponse::Ok().json(symbol_response) - } - Err(e) => HttpResponse::BadRequest().json(ErrorResponse { - error: format!("Couldn't get symbols: {}", e), - }), - } -} diff --git a/crates/wrapper/src/handlers/error.rs b/crates/wrapper/src/handlers/error.rs deleted file mode 100644 index 5912f094..00000000 --- a/crates/wrapper/src/handlers/error.rs +++ /dev/null @@ -1,35 +0,0 @@ -use crate::manager::LspManagerError; -use actix_web::HttpResponse; -use common::api_types::ErrorResponse; - -pub trait IntoHttpResponse { - fn into_http_response(self) -> HttpResponse; -} - -impl IntoHttpResponse for LspManagerError { - fn into_http_response(self) -> HttpResponse { - log::error!("LSP error: {}", self); - match self { - Self::FileNotFound(path) => HttpResponse::BadRequest().json(ErrorResponse { - error: format!("File not found: {}", path), - }), - Self::LspClientNotFound(lang) => { - HttpResponse::InternalServerError().json(ErrorResponse { - error: format!("LSP client not found for {:?}", lang), - }) - } - Self::InternalError(msg) => HttpResponse::InternalServerError().json(ErrorResponse { - error: format!("Internal error: {}", msg), - }), - Self::UnsupportedFileType(path) => HttpResponse::BadRequest().json(ErrorResponse { - error: format!("Unsupported file type: {}", path), - }), - Self::NotImplemented(msg) => HttpResponse::NotImplemented().json(ErrorResponse { - error: format!("Not implemented: {}", msg), - }), - Self::NoLspClientAvailable => HttpResponse::InternalServerError().json(ErrorResponse { - error: "No LSP client available".to_string(), - }), - } - } -} diff --git a/crates/wrapper/src/handlers/find_identifier.rs b/crates/wrapper/src/handlers/find_identifier.rs deleted file mode 100644 index 177fdb33..00000000 --- a/crates/wrapper/src/handlers/find_identifier.rs +++ /dev/null @@ -1,97 +0,0 @@ -use actix_web::{ - web::{Data, Json}, - HttpResponse, -}; - -use crate::{ - handlers::utils::{self, PositionError}, - AppState, -}; - -use log::{error, info}; -use common::api_types::{ - ErrorResponse, FilePosition, FindIdentifierRequest, Identifier, IdentifierResponse, -}; - -/// Finds occurrences of an identifier by name in a file -/// -/// Given a file path and identifier name, returns: -/// - Without position: All matching identifiers in the file -/// - With position: The exact identifier with that name at that position, or 3 closest identifiers with that name -/// -/// Example finding all occurrences of "user_name": -/// ```text -/// let user_name = "John"; // First occurrence -/// println!("{}", user_name); // Second occurrence -/// ``` -/// -/// When a position is provided, it searches for an exact match at that location. -/// If no exact match exists, returns the 3 identifiers closest to the position -/// based on line and character distance, prioritizing lines. -#[utoipa::path( - post, - path = "/symbol/find-identifier", - tag = "symbol", - request_body = FindIdentifierRequest, - responses( - (status = 200, description = "Identifier retrieved successfully", body = IdentifierResponse), - (status = 400, description = "Bad request"), - (status = 500, description = "Internal server error") - ) -)] -pub async fn find_identifier( - data: Data, - info: Json, -) -> HttpResponse { - info!( - "Received identifier request for file: {}, name: {}, position: {:?}", - info.path, info.name, info.position - ); - let file_identifiers = match data.manager.get_file_identifiers(&info.path).await { - Ok(identifiers) => identifiers, - Err(e) => { - error!("Failed to get file identifiers: {:?}", e); - return HttpResponse::InternalServerError().json(ErrorResponse { - error: format!("Failed to get file identifiers: {}", e), - }); - } - }; - - // filter identifiers by name - let name_matched_identifiers: Vec = file_identifiers - .into_iter() - .filter(|id| id.name == info.name) - .collect(); - - if name_matched_identifiers.is_empty() { - return HttpResponse::Ok().json(IdentifierResponse { - identifiers: vec![], - }); - } - - if let Some(position) = &info.position { - match utils::find_identifier_at_position( - name_matched_identifiers.clone(), - &FilePosition { - path: info.path.clone(), - position: position.clone(), - }, - ) - .await - { - Ok(identifier) => HttpResponse::Ok().json(IdentifierResponse { - identifiers: vec![identifier], - }), - Err(PositionError::IdentifierNotFound { closest }) => { - // Not an error case, just closest matches - HttpResponse::Ok().json(IdentifierResponse { - identifiers: closest, - }) - } - } - } else { - HttpResponse::Ok().json(IdentifierResponse { - identifiers: name_matched_identifiers, - }) - } -} diff --git a/crates/wrapper/src/handlers/lsp.rs b/crates/wrapper/src/handlers/lsp.rs index ba5438b7..ac971b31 100644 --- a/crates/wrapper/src/handlers/lsp.rs +++ b/crates/wrapper/src/handlers/lsp.rs @@ -1,12 +1,13 @@ use crate::AppState; use actix_web::{web, HttpResponse}; -use log::{debug, error, info}; use common::api_types::{JsonRpcRequest, JsonRpcResponse}; +use log::{debug, error, info}; /// Forward raw LSP JSON-RPC requests to the LSP server /// /// This endpoint provides direct access to the LSP server by forwarding /// JSON-RPC requests and returning responses with minimal processing. +/// Custom lsproxy/* commands are handled locally. #[utoipa::path( post, path = "/lsp", @@ -30,6 +31,13 @@ pub async fn lsp( ); debug!("LSP request: {:?}", &lsp_req); + // Try to handle as a custom command first + if let Some(response) = + crate::custom_commands::handle_custom_command(&app_state.manager, lsp_req.clone()).await + { + return response; + } + // Forward the request to the LSP server match app_state.manager.lsp(lsp_req).await { Ok(response) => { diff --git a/crates/wrapper/src/handlers/mod.rs b/crates/wrapper/src/handlers/mod.rs index 0386267c..e4089625 100644 --- a/crates/wrapper/src/handlers/mod.rs +++ b/crates/wrapper/src/handlers/mod.rs @@ -1,9 +1,2 @@ -pub mod definitions_in_file; -pub mod error; -pub mod find_definition; -pub mod find_identifier; -pub mod find_referenced_symbols; -pub mod find_references; pub mod lsp; - pub mod utils; diff --git a/crates/wrapper/src/lib.rs b/crates/wrapper/src/lib.rs index 78d70927..a03fb681 100644 --- a/crates/wrapper/src/lib.rs +++ b/crates/wrapper/src/lib.rs @@ -2,6 +2,7 @@ // Exposes modules for testing while keeping binary entry point in main.rs // Local modules +pub mod custom_commands; pub mod handlers; pub mod lsp; pub mod manager; diff --git a/crates/wrapper/src/lsp/client.rs b/crates/wrapper/src/lsp/client.rs index b26933e8..2ca9d2f4 100644 --- a/crates/wrapper/src/lsp/client.rs +++ b/crates/wrapper/src/lsp/client.rs @@ -2,6 +2,8 @@ use crate::lsp::json_rpc::JsonRpc; use crate::lsp::process::Process; use crate::lsp::{ExpectedMessageKey, JsonRpcHandler, ProcessHandler}; use async_trait::async_trait; +use common::utils::file_utils::{fix_relative_uris, search_paths, FileType}; +use common::utils::language_utils::detect_language_string; use log::{debug, error, info, warn}; use lsp_types::{ ClientCapabilities, DidOpenTextDocumentParams, DocumentSymbolClientCapabilities, @@ -11,8 +13,6 @@ use lsp_types::{ TextDocumentClientCapabilities, TextDocumentIdentifier, TextDocumentItem, TextDocumentPositionParams, Url, WorkDoneProgressParams, WorkspaceFolder, }; -use common::utils::file_utils::{fix_relative_uris, search_paths, FileType}; -use common::utils::language_utils::detect_language_string; use std::error::Error; use std::path::{Path, PathBuf}; diff --git a/crates/wrapper/src/lsp/languages/generic.rs b/crates/wrapper/src/lsp/languages/generic.rs index 2ff4051d..88bdd1bc 100644 --- a/crates/wrapper/src/lsp/languages/generic.rs +++ b/crates/wrapper/src/lsp/languages/generic.rs @@ -3,10 +3,10 @@ use std::path::Path; use crate::lsp::{JsonRpcHandler, LspClient, PendingRequests, ProcessHandler}; use async_trait::async_trait; -use lsp_types::InitializeParams; use common::utils::workspace_documents::{ DidOpenConfiguration, WorkspaceDocumentsHandler, DEFAULT_EXCLUDE_PATTERNS, }; +use lsp_types::InitializeParams; use std::error::Error; pub struct GenericLspClient { diff --git a/crates/wrapper/src/main.rs b/crates/wrapper/src/main.rs index a4fac511..e3c3f80b 100644 --- a/crates/wrapper/src/main.rs +++ b/crates/wrapper/src/main.rs @@ -5,18 +5,19 @@ use std::sync::Arc; const DEFAULT_RBENV_ROOT: &str = "/opt/rbenv"; +mod custom_commands; mod handlers; mod lsp; mod manager; -use lsp::client::LspClient; -use lsp::languages::{GenericLspClient, GoplsClient, SorbetClient}; -use lsp::process::ProcessHandler; use common::utils::workspace_documents::{ DidOpenConfiguration, CSHARP_FILE_PATTERNS, C_AND_CPP_FILE_PATTERNS, GOLANG_FILE_PATTERNS, JAVA_FILE_PATTERNS, PHP_FILE_PATTERNS, PYTHON_FILE_PATTERNS, RUBY_FILE_PATTERNS, RUST_FILE_PATTERNS, TYPESCRIPT_AND_JAVASCRIPT_FILE_PATTERNS, }; +use lsp::client::LspClient; +use lsp::languages::{GenericLspClient, GoplsClient, SorbetClient}; +use lsp::process::ProcessHandler; use manager::Manager; /// HTTP wrapper for LSP servers @@ -301,26 +302,6 @@ async fn main() -> std::io::Result<()> { App::new() .app_data(app_state.clone()) .route("/health", web::get().to(health)) - .route( - "/symbol/find-identifier", - web::post().to(handlers::find_identifier::find_identifier), - ) - .route( - "/symbol/find-definition", - web::post().to(handlers::find_definition::find_definition), - ) - .route( - "/symbol/find-references", - web::post().to(handlers::find_references::find_references), - ) - .route( - "/symbol/find-referenced-symbols", - web::post().to(handlers::find_referenced_symbols::find_referenced_symbols), - ) - .route( - "/symbol/definitions-in-file", - web::get().to(handlers::definitions_in_file::definitions_in_file), - ) .route("/lsp", web::post().to(handlers::lsp::lsp)) }) .bind(("0.0.0.0", args.port))? diff --git a/crates/wrapper/src/manager.rs b/crates/wrapper/src/manager.rs index 1af3bd20..8d9264d0 100644 --- a/crates/wrapper/src/manager.rs +++ b/crates/wrapper/src/manager.rs @@ -1,6 +1,4 @@ use crate::lsp::client::LspClient; -use log::{error, warn}; -use lsp_types::{GotoDefinitionResponse, Location, Position, Range}; /// Simplified Manager for lsp-wrapper /// /// Unlike the main Nuanced LSP Manager that orchestrates multiple language servers, @@ -10,6 +8,8 @@ use common::ast_grep::client::AstGrepClient; use common::ast_grep::types::AstGrepMatch; use common::utils::file_utils::uri_to_relative_path_string; use common::utils::workspace_documents::WorkspaceDocuments; +use log::{error, warn}; +use lsp_types::{GotoDefinitionResponse, Location, Position, Range}; use std::sync::Arc; use thiserror::Error; use tokio::sync::Mutex; From 34df5691087e2a0a57ed15ef5c106ac045ce5f81 Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Wed, 26 Nov 2025 11:43:40 +0100 Subject: [PATCH 20/92] Port CLI changes over from nuanced-dev/mcp --- .../typescript/script/generate-version.mjs | 4 +- clients/typescript/src/__generated/version.ts | 2 + clients/typescript/src/cli.ts | 72 ++++ clients/typescript/src/client.ts | 151 ++++--- clients/typescript/src/proxy.ts | 4 +- clients/typescript/src/server.ts | 405 ++++++++++++++++++ clients/typescript/src/types.ts | 53 ++- 7 files changed, 625 insertions(+), 66 deletions(-) create mode 100644 clients/typescript/src/server.ts diff --git a/clients/typescript/script/generate-version.mjs b/clients/typescript/script/generate-version.mjs index a0222523..f84c15e7 100755 --- a/clients/typescript/script/generate-version.mjs +++ b/clients/typescript/script/generate-version.mjs @@ -97,7 +97,9 @@ const DEFAULT_WATCHDOG_IMAGE = `${WATCHDOG_IMAGE_BASE}:${nuancedLSPWatchdogVersi fs.mkdirSync(outDir, { recursive: true }); // Emit TypeScript module (formatted to pass Prettier) -const contents = `// AUTO-GENERATED FILE. Do not edit. +const contents = `/* eslint-ignore */ +/* tslint-ignore */ +// AUTO-GENERATED FILE. Do not edit. // Generated by script/generate-version.mjs export const DEFAULT_PROXY_IMAGE = ${JSON.stringify(DEFAULT_PROXY_IMAGE)}; diff --git a/clients/typescript/src/__generated/version.ts b/clients/typescript/src/__generated/version.ts index d157cc81..0957075e 100644 --- a/clients/typescript/src/__generated/version.ts +++ b/clients/typescript/src/__generated/version.ts @@ -1,3 +1,5 @@ +/* eslint-ignore */ +/* tslint-ignore */ // AUTO-GENERATED FILE. Do not edit. // Generated by script/generate-version.mjs export const DEFAULT_PROXY_IMAGE = diff --git a/clients/typescript/src/cli.ts b/clients/typescript/src/cli.ts index 168f358c..df2e2f0f 100644 --- a/clients/typescript/src/cli.ts +++ b/clients/typescript/src/cli.ts @@ -31,6 +31,7 @@ import type { Result, RunCommandOptions, RunResult, + ServerCommandOptions, StatusCommandOptions, StatusResult, UpCommandOptions, @@ -49,6 +50,12 @@ import { } from "./defaults.js"; import type { NuancedLspClient } from "./client.js"; +async function generateRandomContainerName(): Promise { + const { randomUUID } = await import("node:crypto"); + const id = randomUUID().slice(0, 8); + return `nuanced-lsp-${id}`; +} + // Lazy-load client (avoids startup cost if user only runs --help, etc.) async function lspClient(opts: { containerName?: string; @@ -249,6 +256,27 @@ async function upCommand( }); } +async function serverCommand( + workspace: string, + opts: ServerCommandOptions, +): Promise { + const containerName = await generateRandomContainerName(); + const client = await lspClient({ + containerName, + ...opts, + lspPort: opts.hostPort, + }); + + try { + // Run the LSP server stdio loop + const { runLspServer } = await import("./server.js"); + await runLspServer(client, workspace, opts, process.stdin, process.stdout); + } catch { + // Fatal errors are already logged via window/logMessage + process.exitCode = 1; + } +} + async function downCommand(opts: DownCommandOptions): Promise { const client = await lspClient({ ...opts, @@ -647,6 +675,50 @@ program .option("--env-file ", "Path to an env file") .action(upCommand); +program + .command("server") + .description( + ansi.pink( + "Start a stdio LSP server that forwards requests to the Nuanced LSP container.", + ), + ) + .argument("", "Host workspace directory to mount") + .option( + "--host-port ", + `Host port to map to ${DEFAULT_HOST_PORT}. Note: Use port 0 for a dynamically assigned host port from Docker.`, + (v: string) => parseInt(v, 10), + ) + .option( + "--bind-host ", + "Host/IP to bind (e.g. 127.0.0.1, 0.0.0.0, 192.168.1.10)", + DEFAULT_BIND_HOST, + ) + .option( + "--proxy-image ", + `Nuanced LSP proxy image (default: ${DEFAULT_PROXY_IMAGE})`, + ) + .option("--watchdog-image ", "Nuanced LSP watchdog image") + .option("--wrapper-image ", "Nuanced LSP wrapper image") + .option( + "--language-container-version ", + `Nuanced LSP language container version (default: ${LANGUAGE_CONTAINER_VERSION})`, + ) + .option( + "--timeout ", + `Health check poll loop timeout seconds (<=0 to skip) (default: ${DEFAULT_TIMEOUT_SECS})`, + (v: string) => parseInt(v, 10), + ) + .option("--sudo", "Run Docker commands with sudo") + .option("--ro", "Mount workspace as read-only (default is read-write)") + .option("--debug", "Run the container with debug logging enabled") + .option( + "--env ", + "Set environment variable (repeatable)", + (v: string, prev: string[] | undefined) => (prev ? prev.concat(v) : [v]), + ) + .option("--env-file ", "Path to an env file") + .action(serverCommand); + program .command("down") .description(ansi.pink("Stop the Nuanced LSP container.")) diff --git a/clients/typescript/src/client.ts b/clients/typescript/src/client.ts index c4315234..c7a791f0 100644 --- a/clients/typescript/src/client.ts +++ b/clients/typescript/src/client.ts @@ -19,6 +19,8 @@ import type { StatusResult, LogsResult, PullResult, + JsonRpcRequest, + JsonRpcResponse, } from "./types.js"; import { @@ -29,6 +31,7 @@ import { DEFAULT_TIMEOUT_SECS, DEFAULT_RETRIES, } from "./defaults.js"; +import { err, ok } from "./types.js"; // Precompiled tiny helpers const TRAILING_SLASH_RE = /\/$/; @@ -60,6 +63,7 @@ export class NuancedLspClient { private timeoutSecs: number; private fullLsProxyUrl: string | undefined; + private nextId = 1; // Cache for list-files results to avoid redundant calls // TODO: Add proper cache invalidation when files are modified @@ -230,7 +234,7 @@ export class NuancedLspClient { const { httpRequestWithRetries } = await http(); return httpRequestWithRetries( "GET", - "/v1/system/health", + "/v2/system/health", url, undefined, undefined, @@ -251,17 +255,11 @@ export class NuancedLspClient { return { ok: true, data: this.fileListCache.data }; } - // Cache miss or expired - fetch from server - const url = await this.lsProxyUrl(); - const { httpRequestWithRetries } = await http(); - const result = await httpRequestWithRetries( - "GET", - "/v1/workspace/list-files", - url, - undefined, + // Cache miss or expired - fetch from server via LSP command + const result = await this.request( + "lsproxy/workspace/listFiles", undefined, - this.retries, - this.resolveTimeout(timeoutSecs), + timeoutSecs, ); // Update cache on successful response @@ -280,16 +278,10 @@ export class NuancedLspClient { range?: LspRange | null, timeoutSecs?: number, ): Promise> { - const url = await this.lsProxyUrl(); - const { httpRequestWithRetries } = await http(); - return httpRequestWithRetries( - "POST", - "/v1/workspace/read-source-code", - url, + return this.request( + "lsproxy/workspace/readSourceCode", { path: filePath, range: range ?? null }, - undefined, - this.retries, - this.resolveTimeout(timeoutSecs), + timeoutSecs, ); } @@ -299,16 +291,10 @@ export class NuancedLspClient { filePath: string, timeoutSecs?: number, ): Promise> { - const url = await this.lsProxyUrl(); - const { httpRequestWithRetries } = await http(); - return httpRequestWithRetries( - "GET", - "/v1/symbol/definitions-in-file", - url, - undefined, + return this.request( + "lsproxy/symbol/definitionsInFile", { file_path: filePath }, - this.retries, - this.resolveTimeout(timeoutSecs), + timeoutSecs, ); } @@ -318,20 +304,14 @@ export class NuancedLspClient { includeSourceCode?: boolean, timeoutSecs?: number, ): Promise> { - const url = await this.lsProxyUrl(); - const { httpRequestWithRetries } = await http(); - return httpRequestWithRetries( - "POST", - "/v1/symbol/find-definition", - url, + return this.request( + "lsproxy/symbol/findDefinition", { include_raw_response: !!includeRawResponse, include_source_code: !!includeSourceCode, position: filePosition, }, - undefined, - this.retries, - this.resolveTimeout(timeoutSecs), + timeoutSecs, ); } @@ -341,16 +321,10 @@ export class NuancedLspClient { position?: LspPosition | null, timeoutSecs?: number, ): Promise> { - const url = await this.lsProxyUrl(); - const { httpRequestWithRetries } = await http(); - return httpRequestWithRetries( - "POST", - "/v1/symbol/find-identifier", - url, + return this.request( + "lsproxy/symbol/findIdentifier", { name, path: filePath, position }, - undefined, - this.retries, - this.resolveTimeout(timeoutSecs), + timeoutSecs, ); } @@ -359,19 +333,13 @@ export class NuancedLspClient { fullScan = false, timeoutSecs?: number, ): Promise> { - const url = await this.lsProxyUrl(); - const { httpRequestWithRetries } = await http(); - return httpRequestWithRetries( - "POST", - "/v1/symbol/find-referenced-symbols", - url, + return this.request( + "lsproxy/symbol/findReferencedSymbols", { full_scan: !!fullScan, identifier_position: identifierPosition, }, - undefined, - this.retries, - this.resolveTimeout(timeoutSecs), + timeoutSecs, ); } @@ -381,17 +349,76 @@ export class NuancedLspClient { includeRawResponse?: boolean, timeoutSecs?: number, ): Promise> { - const url = await this.lsProxyUrl(); - const { httpRequestWithRetries } = await http(); - return httpRequestWithRetries( - "POST", - "/v1/symbol/find-references", - url, + return this.request( + "lsproxy/symbol/findReferences", { identifier_position: identifierPosition, include_code_context_lines: contextLines, include_raw_response: !!includeRawResponse, }, + timeoutSecs, + ); + } + + // ---- LSP JSON-RPC forwarding ----------------------------------------------- + + async request( + method: string, + params: any | undefined, + timeoutSecs?: number, + ): Promise> { + const url = await this.lsProxyUrl(); + const { httpRequestWithRetries } = await http(); + const id = this.nextId++; + const request: JsonRpcRequest = { + jsonrpc: "2.0", + id, + method, + params, + }; + const result = await httpRequestWithRetries( + "POST", + "/v2/lsp", + url, + request, + undefined, + this.retries, + this.resolveTimeout(timeoutSecs), + ); + + if (!result.ok) { + return result; + } + + // If JSON-RPC response contains an error, translate to 500 HTTP error + if (result.data.error) { + return err({ + status_code: 500, + error: result.data.error, + }); + } + + // Return just the result data + return ok(result.data.result); + } + + async notify( + method: string, + params: any | undefined, + timeoutSecs?: number, + ): Promise> { + const url = await this.lsProxyUrl(); + const { httpRequestWithRetries } = await http(); + const request: JsonRpcRequest = { + jsonrpc: "2.0", + method, + params, + }; + return httpRequestWithRetries( + "POST", + "/v2/lsp", + url, + request, undefined, this.retries, this.resolveTimeout(timeoutSecs), diff --git a/clients/typescript/src/proxy.ts b/clients/typescript/src/proxy.ts index 639f9cd8..69ef2e75 100644 --- a/clients/typescript/src/proxy.ts +++ b/clients/typescript/src/proxy.ts @@ -342,7 +342,7 @@ export async function up( const h = await pollHttpWithRetries( "GET", - "/v1/system/health", + "/v2/system/health", base, { // Each attempt: quick timeout, tiny retry budget @@ -366,7 +366,7 @@ export async function up( const httpErr = h.data; return err({ error_code: httpErr.status_code ?? 1, - message: `Health check polling loop timed out before successful response: ${httpErr.error}`, + message: `Health check polling loop timed out before successful response: ${JSON.stringify(httpErr.error)}`, stdout: "", stderr: "", }); diff --git a/clients/typescript/src/server.ts b/clients/typescript/src/server.ts new file mode 100644 index 00000000..17ae16b8 --- /dev/null +++ b/clients/typescript/src/server.ts @@ -0,0 +1,405 @@ +import type { Readable, Writable } from "node:stream"; +import type { NuancedLspClient } from "./client.js"; +import type { + JsonRpcMessage, + JsonRpcRequest, + JsonRpcResponse, + ServerCommandOptions, +} from "./types.js"; +import { isErr } from "./types.js"; + +// ---- Types ------------------------------------------------------------------ + +/** + * LSP MessageType for window/logMessage notifications. + */ +enum MessageType { + Error = 1, + Warning = 2, + Info = 3, + Log = 4, + Debug = 5, +} + +// ---- High-level server function --------------------------------------------- + +class LspServer { + private readonly client: NuancedLspClient; + private readonly workspace: string; + private readonly opts: ServerCommandOptions; + private readonly input: Readable; + private readonly output: Writable; + private readonly logLevel: MessageType; + private shutdownReceived = false; + private isShuttingDown = false; + + constructor( + client: NuancedLspClient, + workspace: string, + opts: ServerCommandOptions, + input: Readable, + output: Writable, + ) { + this.client = client; + this.workspace = workspace; + this.opts = opts; + this.input = input; + this.output = output; + this.logLevel = opts.debug ? MessageType.Debug : MessageType.Info; + } + + async run(): Promise { + await this.startServer(); + this.setupSignalHandlers(); + await this.sendLogMessage(MessageType.Info, "Nuanced LSP started"); + + try { + await this.processMessages(); + } catch (err) { + await this.sendLogMessage( + MessageType.Error, + `Fatal Nuanced LSP error: ${err}`, + ); + throw err; + } finally { + await this.stopServer(); + await this.sendLogMessage(MessageType.Info, "Nuanced LSP stopped"); + } + } + + private async startServer(): Promise { + const res = await this.client.up(this.workspace, { + proxyImage: this.opts.proxyImage, + watchdogImage: this.opts.watchdogImage, + wrapperImage: this.opts.wrapperImage, + languageContainerVersion: this.opts.languageContainerVersion, + timeout: this.opts.timeout, + stream: false, + ro: this.opts.ro, + bindHost: this.opts.bindHost, + debug: this.opts.debug, + env: this.opts.env, + envFile: this.opts.envFile, + }); + + if (isErr(res)) { + await this.sendLogMessage( + MessageType.Error, + `Failed to start LSP server container: ${JSON.stringify(res.data)}`, + ); + throw new Error("Failed to start container"); + } + } + + private setupSignalHandlers(): void { + process.on("SIGINT", () => { + this.stopServer().then(() => process.exit(0)); + }); + process.on("SIGTERM", () => { + this.stopServer().then(() => process.exit(0)); + }); + } + + private async stopServer(): Promise { + if (this.isShuttingDown) return; + this.isShuttingDown = true; + + await this.client.down(); + } + + private async processMessages(): Promise { + while (true) { + const message = await readMessage(this.input); + + if (message === null) { + break; + } + + if (message.method === "shutdown") { + await this.handleShutdownRequest(message); + } else if (message.method === "exit") { + this.handleExitNotification(); + } else { + await this.forwardMessage(message); + } + } + } + + private async handleShutdownRequest(message: JsonRpcRequest): Promise { + await this.sendLogMessage( + MessageType.Debug, + "Nuanced LSP shutdown request received", + ); + + this.shutdownReceived = true; + await this.stopServer(); + + const shutdownResponse: JsonRpcResponse = { + jsonrpc: "2.0", + id: message.id ?? null, + result: null, + }; + await writeMessage(this.output, shutdownResponse); + } + + private handleExitNotification(): void { + this.sendLogMessage( + MessageType.Debug, + "Nuanced LSP exit notification received", + ); + + const exitCode = this.shutdownReceived ? 0 : 1; + process.exit(exitCode); + } + + private async forwardMessage(message: JsonRpcRequest): Promise { + await this.sendLogMessage( + MessageType.Debug, + `Nuanced LSP request: ${JSON.stringify(message)}`, + ); + + try { + // Determine if this is a request (has id) or notification (no id) + const isRequest = message.id !== undefined; + const result = isRequest + ? await this.client.request(message.method, message.params, undefined) + : await this.client.notify(message.method, message.params, undefined); + + if (result.ok) { + await this.sendLogMessage( + MessageType.Debug, + `Nuanced LSP result reponse: ${JSON.stringify(result.data)}`, + ); + + // Only send response for requests (notifications don't get responses) + if (isRequest) { + const response: JsonRpcResponse = { + jsonrpc: "2.0", + id: message.id!, + result: result.data, + }; + await writeMessage(this.output, response); + } + } else { + await this.sendLogMessage( + MessageType.Error, + `Nuanced LSP error response: ${JSON.stringify(result.data)}`, + ); + + // Only send error response for requests + if (isRequest) { + // If error is a JsonRpcError (from 500 response), use it directly + // Otherwise create a generic error + let error: { + code: number; + message: string; + data?: any; + }; + if ( + typeof result.data.error === "object" && + result.data.error !== null && + "code" in result.data.error && + "message" in result.data.error + ) { + error = result.data.error as { + code: number; + message: string; + data?: any; + }; + } else { + error = { + code: result.data.status_code ?? -32603, + message: "LSP forwarding error", + data: result.data.error, + }; + } + + const errorResponse: JsonRpcResponse = { + jsonrpc: "2.0", + id: message.id!, + error, + }; + await writeMessage(this.output, errorResponse); + } + } + } catch (err) { + // Only send error response for requests + if (message.id !== undefined) { + const errorResponse: JsonRpcResponse = { + jsonrpc: "2.0", + id: message.id, + error: { + code: -32603, + message: "Internal error", + data: String(err), + }, + }; + await writeMessage(this.output, errorResponse); + } + + await this.sendLogMessage( + MessageType.Error, + `Nuanced LSP forwarding error: ${err}`, + ); + } + } + + private async sendLogMessage( + type: MessageType, + message: string, + ): Promise { + if (type > this.logLevel) return; + + const notification: JsonRpcRequest = { + jsonrpc: "2.0", + method: "window/logMessage", + params: { + type, + message, + }, + }; + + await writeMessage(this.output, notification); + } +} + +/** + * Start an LSP server that manages a container and forwards JSON-RPC requests. + */ +export async function runLspServer( + client: NuancedLspClient, + workspace: string, + opts: ServerCommandOptions, + input: Readable = process.stdin, + output: Writable = process.stdout, +): Promise { + const server = new LspServer(client, workspace, opts, input, output); + await server.run(); +} + +// ---- Helper functions ------------------------------------------------------- + +/** + * Read a single JSON-RPC message from stdin with Content-Length header parsing. + * Returns null when stdin is closed. + */ +async function readMessage(input: Readable): Promise { + return new Promise((resolve, reject) => { + let contentLength: number | null = null; + let buffer = Buffer.alloc(0); + let headersDone = false; + + const dataHandler = (chunk: Buffer) => { + buffer = Buffer.concat([buffer, chunk]); + + if (!headersDone) { + // Try CRLF separator first (LSP standard), then LF + let separator = "\r\n\r\n"; + let lineSeparator = "\r\n"; + let separatorIndex = buffer.indexOf(separator); + + if (separatorIndex === -1) { + separator = "\n\n"; + lineSeparator = "\n"; + separatorIndex = buffer.indexOf(separator); + } + + if (separatorIndex === -1) { + // Haven't received all headers yet + return; + } + + // Extract headers + const headerSection = buffer + .subarray(0, separatorIndex) + .toString("utf8"); + const headers = headerSection.split(lineSeparator); + + for (const header of headers) { + const len = parseContentLength(header); + if (len !== null) { + contentLength = len; + break; + } + } + + if (contentLength === null) { + // Invalid: no Content-Length header + cleanup(); + resolve(null); + return; + } + + // Move past headers + buffer = buffer.subarray(separatorIndex + separator.length); + headersDone = true; + } + + // Check if we have all the content + if (headersDone && buffer.length >= contentLength!) { + // Extract exactly contentLength bytes + const messageContent = buffer + .subarray(0, contentLength!) + .toString("utf8"); + + try { + const message = JSON.parse(messageContent) as JsonRpcRequest; + cleanup(); + resolve(message); + } catch (err) { + // Invalid JSON + cleanup(); + reject(new Error(`Failed to parse JSON-RPC message: ${err}`)); + } + } + }; + + const endHandler = () => { + cleanup(); + resolve(null); + }; + + const errorHandler = (err: Error) => { + cleanup(); + reject(err); + }; + + const cleanup = () => { + input.off("data", dataHandler); + input.off("end", endHandler); + input.off("error", errorHandler); + }; + + input.on("data", dataHandler); + input.on("end", endHandler); + input.on("error", errorHandler); + }); +} + +/** + * Parse Content-Length header from LSP message. + */ +function parseContentLength(header: string): number | null { + const match = /^Content-Length:\s*(\d+)\s*$/i.exec(header.trim()); + return match ? parseInt(match[1], 10) : null; +} + +/** + * Write a JSON-RPC message to stdout with Content-Length header. + */ +function writeMessage( + output: Writable, + message: JsonRpcMessage, +): Promise { + return new Promise((resolve, reject) => { + const content = JSON.stringify(message); + const contentLength = Buffer.byteLength(content, "utf8"); + const header = `Content-Length: ${contentLength}\r\n\r\n`; + const full = header + content; + + output.write(full, (err) => { + if (err) reject(err); + else resolve(); + }); + }); +} diff --git a/clients/typescript/src/types.ts b/clients/typescript/src/types.ts index 572e3935..e3471574 100644 --- a/clients/typescript/src/types.ts +++ b/clients/typescript/src/types.ts @@ -79,7 +79,7 @@ export const HttpErrSchema = z.object({ export type HttpErr = Named; export type HttpResult = Result; -// ---- Health (data-plane /v1/system/health) ---------------------------------- +// ---- Health (data-plane /v2/system/health) ---------------------------------- export const HealthResultSchema = z.object({ status: z.union([z.literal("ok"), z.literal("not ok")]), version: z.string().optional(), @@ -298,6 +298,38 @@ export type FindReferencesResult = Named< "FindReferencesResult" >; +// ---- JSON-RPC types --------------------------------------------------------- + +export interface JsonRpcMessage { + jsonrpc: "2.0"; +} + +export interface JsonRpcRequest extends JsonRpcMessage { + id?: string | number; + method: string; + params?: any; +} + +export interface JsonRpcResponse extends JsonRpcMessage { + id: string | number | null; + result?: any; + error?: JsonRpcError; +} + +export interface JsonRpcError { + code: JsonRpcErrorCode; + message: string; + data?: any; +} + +export enum JsonRpcErrorCode { + ParseError = -32700, + InvalidRequest = -32600, + MethodNotFound = -32601, + InvalidParams = -32602, + InternalError = -32603, +} + // ---- CLI Command Options --------------------------------------------------- export const BaseCommandOptionsSchema = z.object({ json: z.boolean().optional(), @@ -442,3 +474,22 @@ export type FindReferencesOptions = Named< typeof FindReferencesOptionsSchema, "FindReferencesOptions" >; + +export const ServerCommandOptionsSchema = BaseCommandOptionsSchema.extend({ + hostPort: z.number().int().min(0).optional(), + proxyImage: z.string().optional(), + wrapperImage: z.string().optional(), + watchdogImage: z.string().optional(), + languageContainerVersion: z.string().optional(), + timeout: z.number().optional(), + sudo: z.boolean().optional(), + ro: z.boolean().optional(), + bindHost: z.string().optional(), + debug: z.boolean().optional(), + env: z.array(z.string()).optional(), + envFile: z.string().optional(), +}); +export type ServerCommandOptions = Named< + typeof ServerCommandOptionsSchema, + "ServerCommandOptions" +>; From dc71d5e765c01a8b17850c01f71f593e91e6f7cb Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Wed, 26 Nov 2025 14:41:41 +0100 Subject: [PATCH 21/92] Bump versions --- Cargo.lock | 6 +++--- Cargo.toml | 2 +- README.md | 4 ++-- clients/typescript/CHANGELOG.md | 10 ++++++++++ clients/typescript/README.md | 6 +++--- clients/typescript/config/version.json | 8 ++++---- clients/typescript/package.json | 2 +- clients/typescript/src/__generated/version.ts | 8 ++++---- crates/proxy/src/container/mod.rs | 2 +- scripts/build-rust-images.sh | 12 ++++++------ 10 files changed, 35 insertions(+), 25 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5bf46f39..e14a32cc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -746,7 +746,7 @@ checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" [[package]] name = "common" -version = "0.4.8" +version = "0.5.0" dependencies = [ "ast-grep", "async-trait", @@ -2413,7 +2413,7 @@ dependencies = [ [[package]] name = "proxy" -version = "0.4.8" +version = "0.5.0" dependencies = [ "actix-cors", "actix-files", @@ -4370,7 +4370,7 @@ checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" [[package]] name = "wrapper" -version = "0.4.8" +version = "0.5.0" dependencies = [ "actix-web", "ast-grep", diff --git a/Cargo.toml b/Cargo.toml index 761938d4..1234312d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ members = [ resolver = "2" [workspace.package] -version = "0.4.8" +version = "0.5.0" edition = "2021" authors = ["Nuanced"] diff --git a/README.md b/README.md index 3c4ea033..69519f4f 100644 --- a/README.md +++ b/README.md @@ -438,7 +438,7 @@ docker rm -f nuanced-lsp-proxy **`RUST_CONTAINER_VERSION`** (optional) - Docker image tag for Rust containers (proxy, wrapper, watchdog) -- Default: `0.4.8` +- Default: `0.5.0` - Use `latest` for development or specify a version for production - Primarily used by test scripts and CI/CD pipelines @@ -472,7 +472,7 @@ ENABLED_LANGUAGES="go" NUANCED_LSP_MAX_MEMORY=16384 ./scripts/start-proxy.sh RUST_CONTAINER_VERSION=latest LANGUAGE_CONTAINER_VERSION=1.0.0 ./scripts/test.sh # Test with production versions -RUST_CONTAINER_VERSION=0.4.8 LANGUAGE_CONTAINER_VERSION=1.0.0 ./scripts/test.sh +RUST_CONTAINER_VERSION=0.5.0 LANGUAGE_CONTAINER_VERSION=1.0.0 ./scripts/test.sh ``` ### Supported languages and LSP image sizes diff --git a/clients/typescript/CHANGELOG.md b/clients/typescript/CHANGELOG.md index a47327fb..4c4472d3 100644 --- a/clients/typescript/CHANGELOG.md +++ b/clients/typescript/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +Support using Nuanced LSP as an LSP server through CLI and in VS Code. + +- `nuanced-lsp` version 0.3.19 +- `nuanced-lsp-proxy` image version 0.5.0 +- `nuanced-lsp-watchdog` image version 0.5.0 +- `nuanced-lsp-wrapper` image version 0.5.0 +- Version 1.0.0 for Nuanced language images + ## [0.3.18] - 2025-11-25 Nuanced LSP rearchitecture release for dynamic container orchestration. diff --git a/clients/typescript/README.md b/clients/typescript/README.md index 19735416..f4fa8ed6 100644 --- a/clients/typescript/README.md +++ b/clients/typescript/README.md @@ -86,9 +86,9 @@ You can override the default container images using CLI flags: ```bash nuanced-lsp up /path/to/workspace \ - --proxy-image ghcr.io/nuanced-dev/nuanced-lsp-proxy:0.4.8 \ - --wrapper-image ghcr.io/nuanced-dev/nuanced-lsp-wrapper:0.4.8 \ - --watchdog-image ghcr.io/nuanced-dev/nuanced-lsp-watchdog:0.4.8 \ + --proxy-image ghcr.io/nuanced-dev/nuanced-lsp-proxy:0.5.0 \ + --wrapper-image ghcr.io/nuanced-dev/nuanced-lsp-wrapper:0.5.0 \ + --watchdog-image ghcr.io/nuanced-dev/nuanced-lsp-watchdog:0.5.0 \ --language-container-version 1.0.0 ``` diff --git a/clients/typescript/config/version.json b/clients/typescript/config/version.json index f833c78d..68dd04d7 100644 --- a/clients/typescript/config/version.json +++ b/clients/typescript/config/version.json @@ -1,7 +1,7 @@ { - "lsp-version": "0.3.18", - "lsp-proxy-version": "0.4.8", - "lsp-wrapper-version": "0.4.8", - "lsp-watchdog-version": "0.4.8", + "lsp-version": "0.3.19", + "lsp-proxy-version": "0.5.0", + "lsp-wrapper-version": "0.5.0", + "lsp-watchdog-version": "0.5.0", "language-container-version": "1.0.0" } diff --git a/clients/typescript/package.json b/clients/typescript/package.json index c025b068..00d5fb5a 100644 --- a/clients/typescript/package.json +++ b/clients/typescript/package.json @@ -61,7 +61,7 @@ "engines": { "node": ">=18" }, - "version": "0.3.18", + "version": "0.3.19", "publishConfig": { "access": "public" } diff --git a/clients/typescript/src/__generated/version.ts b/clients/typescript/src/__generated/version.ts index 0957075e..989cf09d 100644 --- a/clients/typescript/src/__generated/version.ts +++ b/clients/typescript/src/__generated/version.ts @@ -3,10 +3,10 @@ // AUTO-GENERATED FILE. Do not edit. // Generated by script/generate-version.mjs export const DEFAULT_PROXY_IMAGE = - "ghcr.io/nuanced-dev/nuanced-lsp-proxy:0.4.8"; + "ghcr.io/nuanced-dev/nuanced-lsp-proxy:0.5.0"; export const DEFAULT_WRAPPER_IMAGE = - "ghcr.io/nuanced-dev/nuanced-lsp-wrapper:0.4.8"; + "ghcr.io/nuanced-dev/nuanced-lsp-wrapper:0.5.0"; export const DEFAULT_WATCHDOG_IMAGE = - "ghcr.io/nuanced-dev/nuanced-lsp-watchdog:0.4.8"; -export const VERSION = "0.3.18"; + "ghcr.io/nuanced-dev/nuanced-lsp-watchdog:0.5.0"; +export const VERSION = "0.3.19"; export const LANGUAGE_CONTAINER_VERSION = "1.0.0"; diff --git a/crates/proxy/src/container/mod.rs b/crates/proxy/src/container/mod.rs index c4c63572..825b5f33 100644 --- a/crates/proxy/src/container/mod.rs +++ b/crates/proxy/src/container/mod.rs @@ -15,7 +15,7 @@ pub mod orchestrator; /// Default version tag for Rust containers (wrapper, proxy, watchdog) /// Can be overridden with RUST_CONTAINER_VERSION environment variable -pub const DEFAULT_RUST_CONTAINER_VERSION: &str = "0.4.8"; +pub const DEFAULT_RUST_CONTAINER_VERSION: &str = "0.5.0"; /// Default version tag for language containers (python, ruby, typescript, etc.) /// Can be overridden with LANGUAGE_CONTAINER_VERSION environment variable diff --git a/scripts/build-rust-images.sh b/scripts/build-rust-images.sh index d19c16f9..f070a6d1 100755 --- a/scripts/build-rust-images.sh +++ b/scripts/build-rust-images.sh @@ -9,7 +9,7 @@ set -e # - Builds WITHOUT cache (use --use-cache to enable caching) # - Builds for local platform only (use --multiarch for amd64+arm64) # - Builds Rust binaries first before Docker images (only for single-arch builds) -# - Tags images as :latest (use --tag=0.4.8 for custom tag) +# - Tags images as :latest (use --tag=0.5.0 for custom tag) # - Does NOT push (use --registry to push to ghcr/dockerhub/local) # - Builds in PARALLEL (use --sequential to disable) # @@ -23,9 +23,9 @@ set -e # --help, -h Show help message # # Examples: -# ./scripts/build-rust-images.sh --tag=0.4.8 -# ./scripts/build-rust-images.sh --multiarch --tag=0.4.8 --registry=ghcr -# ./scripts/build-rust-images.sh --multiarch --load --tag=0.4.8 +# ./scripts/build-rust-images.sh --tag=0.5.0 +# ./scripts/build-rust-images.sh --multiarch --tag=0.5.0 --registry=ghcr +# ./scripts/build-rust-images.sh --multiarch --load --tag=0.5.0 # Colors GREEN='\033[0;32m' @@ -80,8 +80,8 @@ for arg in "$@"; do echo " --help, -h Show this help message" echo "" echo "Examples:" - echo " $0 --tag=0.4.8" - echo " $0 --multiarch --tag=0.4.8 --registry=ghcr" + echo " $0 --tag=0.5.0" + echo " $0 --multiarch --tag=0.5.0 --registry=ghcr" exit 0 ;; *) From 7e45ccc63436dd912106ed19ecb6763b8811a940 Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Thu, 27 Nov 2025 11:58:43 +0100 Subject: [PATCH 22/92] Include response in assertion failure --- .../tests/container_orchestration_test.rs | 42 ++++++++++++++++--- 1 file changed, 36 insertions(+), 6 deletions(-) diff --git a/crates/proxy/tests/container_orchestration_test.rs b/crates/proxy/tests/container_orchestration_test.rs index 81b89b6a..8c40e220 100644 --- a/crates/proxy/tests/container_orchestration_test.rs +++ b/crates/proxy/tests/container_orchestration_test.rs @@ -377,7 +377,12 @@ async fn test_service_health() -> Result<(), Box> { .send() .await?; - assert!(response.status().is_success()); + assert!( + response.status().is_success(), + "request failed: {}: {}", + response.status(), + response.text().await.unwrap_or_default() + ); let health: serde_json::Value = response.json().await?; assert_eq!(health["status"], "ok"); @@ -439,7 +444,12 @@ async fn test_container_spawn_on_request() -> Result<(), Box = docker @@ -487,7 +497,12 @@ async fn test_request_forwarding() -> Result<(), Box> { .send() .await?; - assert!(response.status().is_success()); + assert!( + response.status().is_success(), + "request failed: {}: {}", + response.status(), + response.text().await.unwrap_or_default() + ); let body: serde_json::Value = response.json().await?; // Should have result.definitions field (even if empty) @@ -604,7 +619,12 @@ async fn test_list_files() -> Result<(), Box> { .send() .await?; - assert!(response.status().is_success()); + assert!( + response.status().is_success(), + "request failed: {}: {}", + response.status(), + response.text().await.unwrap_or_default() + ); let body: serde_json::Value = response.json().await?; // The result should be a direct array of filenames @@ -640,7 +660,12 @@ async fn test_find_references() -> Result<(), Box> { .send() .await?; - assert!(response.status().is_success()); + assert!( + response.status().is_success(), + "request failed: {}: {}", + response.status(), + response.text().await.unwrap_or_default() + ); let body: serde_json::Value = response.json().await?; // Should have result.references field @@ -682,7 +707,12 @@ async fn test_find_references_with_context_lines() -> Result<(), Box Date: Thu, 27 Nov 2025 11:59:09 +0100 Subject: [PATCH 23/92] Extract paths from lsproxy methods for dispatch --- crates/proxy/src/handlers/lsp.rs | 53 ++++++++++++++++++++++++-------- 1 file changed, 41 insertions(+), 12 deletions(-) diff --git a/crates/proxy/src/handlers/lsp.rs b/crates/proxy/src/handlers/lsp.rs index 4000dc02..88d5ea35 100644 --- a/crates/proxy/src/handlers/lsp.rs +++ b/crates/proxy/src/handlers/lsp.rs @@ -3,7 +3,10 @@ use crate::handlers::container_proxy; use crate::AppState; use actix_web::web::{Data, Json}; use actix_web::HttpResponse; -use common::api_types::{JsonRpcRequest, JsonRpcResponse}; +use common::api_types::{ + FileSymbolsRequest, FindIdentifierRequest, GetDefinitionRequest, GetReferencedSymbolsRequest, + GetReferencesRequest, JsonRpcRequest, JsonRpcResponse, +}; use log::{debug, error, info, warn}; use lsp_types::{ DeclarationCapability, FoldingRangeProviderCapability, HoverProviderCapability, @@ -45,7 +48,11 @@ pub async fn lsp(data: Data, request: Json) -> HttpRes } // For language feature requests, extract document URI and route to appropriate backend - let document_uri = match extract_document_uri(lsp_req.params.as_ref()) { + let document_uri = match lsp_req + .params + .as_ref() + .and_then(|p| extract_document_uri(&lsp_req.method, p)) + { Some(uri) => uri, None => { warn!( @@ -207,17 +214,39 @@ fn handle_lifecycle_request(request: &JsonRpcRequest) -> Option { } /// Extract document URI from JSON-RPC request parameters -fn extract_document_uri(params: Option<&Value>) -> Option { - // Try textDocument.uri - if let Some(uri) = params - .and_then(|p| p.get("textDocument")) - .and_then(|td| td.get("uri")) - .and_then(|u| u.as_str()) - { - return Some(uri.to_string()); +fn extract_document_uri(method: &str, params: &Value) -> Option { + match method { + "lsproxy/symbol/findDefinition" => { + serde_json::from_value::(params.clone()) + .map(|p| p.position.path) + .ok() + } + "lsproxy/symbol/findReferences" => { + serde_json::from_value::(params.clone()) + .map(|p| p.identifier_position.path) + .ok() + } + "lsproxy/symbol/definitionsInFile" => { + serde_json::from_value::(params.clone()) + .map(|p| p.file_path) + .ok() + } + "lsproxy/symbol/findIdentifier" => { + serde_json::from_value::(params.clone()) + .map(|p| p.path) + .ok() + } + "lsproxy/symbol/findReferencedSymbols" => { + serde_json::from_value::(params.clone()) + .map(|p| p.identifier_position.path) + .ok() + } + _ => params + .get("textDocument") + .and_then(|td| td.get("uri")) + .and_then(|u| u.as_str()) + .map(|uri| uri.to_string()), } - - None } /// Recursively convert paths in JSON value from host to container From 6304a5acc6c65e24d0b21cfab5c743b49d8d2d12 Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Thu, 27 Nov 2025 12:07:33 +0100 Subject: [PATCH 24/92] Construct proper URIs for custom command paths --- crates/proxy/src/handlers/lsp.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/crates/proxy/src/handlers/lsp.rs b/crates/proxy/src/handlers/lsp.rs index 88d5ea35..20e09355 100644 --- a/crates/proxy/src/handlers/lsp.rs +++ b/crates/proxy/src/handlers/lsp.rs @@ -51,7 +51,7 @@ pub async fn lsp(data: Data, request: Json) -> HttpRes let document_uri = match lsp_req .params .as_ref() - .and_then(|p| extract_document_uri(&lsp_req.method, p)) + .and_then(|p| extract_document_uri(&lsp_req.method, p, &data.workspace_path)) { Some(uri) => uri, None => { @@ -214,31 +214,31 @@ fn handle_lifecycle_request(request: &JsonRpcRequest) -> Option { } /// Extract document URI from JSON-RPC request parameters -fn extract_document_uri(method: &str, params: &Value) -> Option { +fn extract_document_uri(method: &str, params: &Value, workspace_path: &str) -> Option { match method { "lsproxy/symbol/findDefinition" => { serde_json::from_value::(params.clone()) - .map(|p| p.position.path) + .map(|p| format!("file://{workspace_path}/{}", p.position.path)) .ok() } "lsproxy/symbol/findReferences" => { serde_json::from_value::(params.clone()) - .map(|p| p.identifier_position.path) + .map(|p| format!("file://{workspace_path}/{}", p.identifier_position.path)) .ok() } "lsproxy/symbol/definitionsInFile" => { serde_json::from_value::(params.clone()) - .map(|p| p.file_path) + .map(|p| format!("file://{workspace_path}/{}", p.file_path)) .ok() } "lsproxy/symbol/findIdentifier" => { serde_json::from_value::(params.clone()) - .map(|p| p.path) + .map(|p| format!("file://{workspace_path}/{}", p.path)) .ok() } "lsproxy/symbol/findReferencedSymbols" => { serde_json::from_value::(params.clone()) - .map(|p| p.identifier_position.path) + .map(|p| format!("file://{workspace_path}/{}", p.identifier_position.path)) .ok() } _ => params From 002a51c88b9469e1a8cc4171e3db8ad9c0bbc838 Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Thu, 27 Nov 2025 12:14:15 +0100 Subject: [PATCH 25/92] Eliminate params clone --- crates/proxy/src/handlers/lsp.rs | 48 +++++++++++++++----------------- 1 file changed, 23 insertions(+), 25 deletions(-) diff --git a/crates/proxy/src/handlers/lsp.rs b/crates/proxy/src/handlers/lsp.rs index 20e09355..53c8b30e 100644 --- a/crates/proxy/src/handlers/lsp.rs +++ b/crates/proxy/src/handlers/lsp.rs @@ -216,31 +216,29 @@ fn handle_lifecycle_request(request: &JsonRpcRequest) -> Option { /// Extract document URI from JSON-RPC request parameters fn extract_document_uri(method: &str, params: &Value, workspace_path: &str) -> Option { match method { - "lsproxy/symbol/findDefinition" => { - serde_json::from_value::(params.clone()) - .map(|p| format!("file://{workspace_path}/{}", p.position.path)) - .ok() - } - "lsproxy/symbol/findReferences" => { - serde_json::from_value::(params.clone()) - .map(|p| format!("file://{workspace_path}/{}", p.identifier_position.path)) - .ok() - } - "lsproxy/symbol/definitionsInFile" => { - serde_json::from_value::(params.clone()) - .map(|p| format!("file://{workspace_path}/{}", p.file_path)) - .ok() - } - "lsproxy/symbol/findIdentifier" => { - serde_json::from_value::(params.clone()) - .map(|p| format!("file://{workspace_path}/{}", p.path)) - .ok() - } - "lsproxy/symbol/findReferencedSymbols" => { - serde_json::from_value::(params.clone()) - .map(|p| format!("file://{workspace_path}/{}", p.identifier_position.path)) - .ok() - } + "lsproxy/symbol/findDefinition" => params + .get("position") + .and_then(|p| p.get("path")) + .and_then(|p| p.as_str()) + .map(|p| format!("file://{workspace_path}/{}", p)), + "lsproxy/symbol/findReferences" => params + .get("identifier_position") + .and_then(|ip| ip.get("path")) + .and_then(|p| p.as_str()) + .map(|p| format!("file://{workspace_path}/{}", p)), + "lsproxy/symbol/definitionsInFile" => params + .get("file_path") + .and_then(|fp| fp.as_str()) + .map(|p| format!("file://{workspace_path}/{}", p)), + "lsproxy/symbol/findIdentifier" => params + .get("path") + .and_then(|p| p.as_str()) + .map(|p| format!("file://{workspace_path}/{}", p)), + "lsproxy/symbol/findReferencedSymbols" => params + .get("identifier_position") + .and_then(|ip| ip.get("path")) + .and_then(|p| p.as_str()) + .map(|p| format!("file://{workspace_path}/{}", p)), _ => params .get("textDocument") .and_then(|td| td.get("uri")) From 41024ceb3e42f5c6489c52394c325a89f0f949e2 Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Thu, 27 Nov 2025 12:18:52 +0100 Subject: [PATCH 26/92] Align request/response type names with endpoint name --- crates/common/src/api_types.rs | 16 ++++++++-------- crates/proxy/src/container/http_client.rs | 16 ++++++++-------- crates/proxy/src/handlers/lsp.rs | 4 ++-- .../src/custom_commands/definitions_in_file.rs | 4 ++-- .../src/custom_commands/find_definition.rs | 8 ++++---- .../src/custom_commands/find_identifier.rs | 10 +++++----- .../custom_commands/find_referenced_symbols.rs | 8 ++++---- .../src/custom_commands/find_references.rs | 8 ++++---- 8 files changed, 37 insertions(+), 37 deletions(-) diff --git a/crates/common/src/api_types.rs b/crates/common/src/api_types.rs index 0083595a..3b48da2c 100644 --- a/crates/common/src/api_types.rs +++ b/crates/common/src/api_types.rs @@ -625,7 +625,7 @@ pub struct Identifier { } #[derive(Serialize, Deserialize, ToSchema, IntoParams)] -pub struct GetDefinitionRequest { +pub struct FindDefinitionRequest { pub position: FilePosition, /// Whether to include the source code around the symbol's identifier in the response. @@ -643,7 +643,7 @@ pub struct GetDefinitionRequest { } #[derive(Serialize, Deserialize, ToSchema, IntoParams)] -pub struct GetReferencesRequest { +pub struct FindReferencesRequest { pub identifier_position: FilePosition, /// Whether to include the source code of the symbol in the response. @@ -667,7 +667,7 @@ pub struct GetReferencesRequest { /// For example, if the position points to a function name, the response will include /// all symbols referenced within that function's implementation. #[derive(Serialize, Deserialize, ToSchema, IntoParams)] -pub struct GetReferencedSymbolsRequest { +pub struct FindReferencedSymbolsRequest { /// Whether to use the more permissive rules to find referenced symbols. This will be not just /// code that is executed but also things like type hints and chained indirection. /// Defaults to false. @@ -681,7 +681,7 @@ pub struct GetReferencedSymbolsRequest { /// Request to get the symbols in a file. #[derive(Serialize, Deserialize, ToSchema, IntoParams)] -pub struct FileSymbolsRequest { +pub struct DefinitionsInFileRequest { /// The path to the file to get the symbols for, relative to the root of the workspace. #[schema(example = "src/main.py")] pub file_path: String, @@ -720,7 +720,7 @@ pub struct WorkspaceSymbolsRequest { /// ``` /// The definition(s) will be `[{"path": "src/main.py", "line": 0, "character": 6}]`. #[derive(Debug, PartialEq, Clone, Serialize, Deserialize, ToSchema)] -pub struct DefinitionResponse { +pub struct FindDefinitionResponse { #[serde(skip_serializing_if = "Option::is_none")] /// The raw response from the langserver. /// @@ -752,7 +752,7 @@ pub struct DefinitionResponse { /// ``` /// The references will be `[{"path": "src/main.py", "line": 5, "character": 7}]`. #[derive(Debug, PartialEq, Clone, Serialize, Deserialize, ToSchema)] -pub struct ReferencesResponse { +pub struct FindReferencesResponse { #[serde(skip_serializing_if = "Option::is_none")] /// The raw response from the langserver. /// @@ -775,7 +775,7 @@ pub struct ReferencesResponse { /// - external_symbols: References to symbols from outside the workspace (built-in functions, external libraries) /// - not_found: References where the symbol definition could not be found #[derive(Debug, PartialEq, Clone, Serialize, Deserialize, ToSchema)] -pub struct ReferencedSymbolsResponse { +pub struct FindReferencedSymbolsResponse { pub workspace_symbols: Vec, pub external_symbols: Vec, pub not_found: Vec, @@ -822,7 +822,7 @@ pub struct FindIdentifierRequest { #[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] #[serde(rename_all = "camelCase")] -pub struct IdentifierResponse { +pub struct FindIdentifierResponse { pub identifiers: Vec, } diff --git a/crates/proxy/src/container/http_client.rs b/crates/proxy/src/container/http_client.rs index 2b755f69..de71be82 100644 --- a/crates/proxy/src/container/http_client.rs +++ b/crates/proxy/src/container/http_client.rs @@ -21,8 +21,8 @@ impl ContainerHttpClient { /// Find definition for a symbol pub async fn find_definition( &self, - request: &GetDefinitionRequest, - ) -> Result> { + request: &FindDefinitionRequest, + ) -> Result> { let url = format!("{}/symbol/find-definition", self.base_url); let response = self.client.post(&url).json(request).send().await?; @@ -37,8 +37,8 @@ impl ContainerHttpClient { /// Find references for a symbol pub async fn find_references( &self, - request: &GetReferencesRequest, - ) -> Result> { + request: &FindReferencesRequest, + ) -> Result> { let url = format!("{}/symbol/find-references", self.base_url); let response = self.client.post(&url).json(request).send().await?; @@ -54,7 +54,7 @@ impl ContainerHttpClient { pub async fn find_identifier( &self, request: &FindIdentifierRequest, - ) -> Result> { + ) -> Result> { let url = format!("{}/symbol/find-identifier", self.base_url); let response = self.client.post(&url).json(request).send().await?; @@ -69,8 +69,8 @@ impl ContainerHttpClient { /// Find referenced symbols within a function pub async fn find_referenced_symbols( &self, - request: &GetReferencedSymbolsRequest, - ) -> Result> { + request: &FindReferencedSymbolsRequest, + ) -> Result> { let url = format!("{}/symbol/find-referenced-symbols", self.base_url); let response = self.client.post(&url).json(request).send().await?; @@ -85,7 +85,7 @@ impl ContainerHttpClient { /// Get all definitions in a file pub async fn definitions_in_file( &self, - request: &FileSymbolsRequest, + request: &DefinitionsInFileRequest, ) -> Result, Box> { let url = format!("{}/symbol/definitions-in-file", self.base_url); let response = self.client.get(&url).query(request).send().await?; diff --git a/crates/proxy/src/handlers/lsp.rs b/crates/proxy/src/handlers/lsp.rs index 53c8b30e..5fda6d48 100644 --- a/crates/proxy/src/handlers/lsp.rs +++ b/crates/proxy/src/handlers/lsp.rs @@ -4,8 +4,8 @@ use crate::AppState; use actix_web::web::{Data, Json}; use actix_web::HttpResponse; use common::api_types::{ - FileSymbolsRequest, FindIdentifierRequest, GetDefinitionRequest, GetReferencedSymbolsRequest, - GetReferencesRequest, JsonRpcRequest, JsonRpcResponse, + DefinitionsInFileRequest, FindDefinitionRequest, FindIdentifierRequest, + FindReferencedSymbolsRequest, FindReferencesRequest, JsonRpcRequest, JsonRpcResponse, }; use log::{debug, error, info, warn}; use lsp_types::{ diff --git a/crates/wrapper/src/custom_commands/definitions_in_file.rs b/crates/wrapper/src/custom_commands/definitions_in_file.rs index 8c0218b7..96cdf847 100644 --- a/crates/wrapper/src/custom_commands/definitions_in_file.rs +++ b/crates/wrapper/src/custom_commands/definitions_in_file.rs @@ -1,6 +1,6 @@ use crate::manager::Manager; use actix_web::HttpResponse; -use common::api_types::{FileSymbolsRequest, JsonRpcRequest, JsonRpcResponse, Symbol}; +use common::api_types::{DefinitionsInFileRequest, JsonRpcRequest, JsonRpcResponse, Symbol}; use log::{error, info}; /// Get symbols in a specific file (uses ast-grep) @@ -31,7 +31,7 @@ pub async fn handle(manager: &Manager, request: JsonRpcRequest) -> HttpResponse } }; - let info: FileSymbolsRequest = match serde_json::from_value(params) { + let info: DefinitionsInFileRequest = match serde_json::from_value(params) { Ok(info) => info, Err(e) => { error!("Invalid parameters for definitionsInFile: {}", e); diff --git a/crates/wrapper/src/custom_commands/find_definition.rs b/crates/wrapper/src/custom_commands/find_definition.rs index 6c948ba0..c148883c 100644 --- a/crates/wrapper/src/custom_commands/find_definition.rs +++ b/crates/wrapper/src/custom_commands/find_definition.rs @@ -2,8 +2,8 @@ use crate::handlers::utils; use crate::manager::{LspManagerError, Manager}; use actix_web::HttpResponse; use common::api_types::{ - CodeContext, DefinitionResponse, FilePosition, FileRange, GetDefinitionRequest, JsonRpcRequest, - JsonRpcResponse, Position, Range, + CodeContext, FilePosition, FileRange, FindDefinitionRequest, FindDefinitionResponse, + JsonRpcRequest, JsonRpcResponse, Position, Range, }; use common::utils::file_utils::uri_to_relative_path_string; use log::{error, info, warn}; @@ -40,7 +40,7 @@ pub async fn handle(manager: &Manager, request: JsonRpcRequest) -> HttpResponse } }; - let info: GetDefinitionRequest = match serde_json::from_value(params) { + let info: FindDefinitionRequest = match serde_json::from_value(params) { Ok(info) => info, Err(e) => { error!("Invalid parameters for findDefinition: {}", e); @@ -113,7 +113,7 @@ pub async fn handle(manager: &Manager, request: JsonRpcRequest) -> HttpResponse None }; - let response = DefinitionResponse { + let response = FindDefinitionResponse { raw_response: if info.include_raw_response { Some(serde_json::to_value(&definitions).unwrap()) } else { diff --git a/crates/wrapper/src/custom_commands/find_identifier.rs b/crates/wrapper/src/custom_commands/find_identifier.rs index c358aefe..00f1ea9a 100644 --- a/crates/wrapper/src/custom_commands/find_identifier.rs +++ b/crates/wrapper/src/custom_commands/find_identifier.rs @@ -2,7 +2,7 @@ use crate::handlers::utils; use crate::manager::Manager; use actix_web::HttpResponse; use common::api_types::{ - FilePosition, FindIdentifierRequest, Identifier, IdentifierResponse, JsonRpcRequest, + FilePosition, FindIdentifierRequest, FindIdentifierResponse, Identifier, JsonRpcRequest, JsonRpcResponse, }; use log::{error, info}; @@ -54,7 +54,7 @@ pub async fn handle(manager: &Manager, request: JsonRpcRequest) -> HttpResponse .collect(); if name_matched_identifiers.is_empty() { - let response = IdentifierResponse { + let response = FindIdentifierResponse { identifiers: vec![], }; let json_rpc_response = JsonRpcResponse::new_result(req_id, response); @@ -72,7 +72,7 @@ pub async fn handle(manager: &Manager, request: JsonRpcRequest) -> HttpResponse .await { Ok(identifier) => { - let response = IdentifierResponse { + let response = FindIdentifierResponse { identifiers: vec![identifier], }; let json_rpc_response = JsonRpcResponse::new_result(req_id, response); @@ -80,7 +80,7 @@ pub async fn handle(manager: &Manager, request: JsonRpcRequest) -> HttpResponse } Err(utils::PositionError::IdentifierNotFound { closest }) => { // Not an error case, just closest matches - let response = IdentifierResponse { + let response = FindIdentifierResponse { identifiers: closest, }; let json_rpc_response = JsonRpcResponse::new_result(req_id, response); @@ -88,7 +88,7 @@ pub async fn handle(manager: &Manager, request: JsonRpcRequest) -> HttpResponse } } } else { - let response = IdentifierResponse { + let response = FindIdentifierResponse { identifiers: name_matched_identifiers, }; let json_rpc_response = JsonRpcResponse::new_result(req_id, response); diff --git a/crates/wrapper/src/custom_commands/find_referenced_symbols.rs b/crates/wrapper/src/custom_commands/find_referenced_symbols.rs index ceacb2e6..2457b391 100644 --- a/crates/wrapper/src/custom_commands/find_referenced_symbols.rs +++ b/crates/wrapper/src/custom_commands/find_referenced_symbols.rs @@ -1,8 +1,8 @@ use crate::manager::Manager; use actix_web::HttpResponse; use common::api_types::{ - get_mount_dir, FilePosition, GetReferencedSymbolsRequest, Identifier, JsonRpcRequest, - JsonRpcResponse, Position, ReferenceWithSymbolDefinitions, ReferencedSymbolsResponse, + get_mount_dir, FilePosition, FindReferencedSymbolsRequest, FindReferencedSymbolsResponse, + Identifier, JsonRpcRequest, JsonRpcResponse, Position, ReferenceWithSymbolDefinitions, }; use common::utils::file_utils::uri_to_relative_path_string; use log::{error, info}; @@ -41,7 +41,7 @@ pub async fn handle(manager: &Manager, request: JsonRpcRequest) -> HttpResponse } }; - let info: GetReferencedSymbolsRequest = match serde_json::from_value(params) { + let info: FindReferencedSymbolsRequest = match serde_json::from_value(params) { Ok(info) => info, Err(e) => { error!("Invalid parameters for findReferencedSymbols: {}", e); @@ -265,7 +265,7 @@ pub async fn handle(manager: &Manager, request: JsonRpcRequest) -> HttpResponse } }); - let response = ReferencedSymbolsResponse { + let response = FindReferencedSymbolsResponse { workspace_symbols, external_symbols, not_found, diff --git a/crates/wrapper/src/custom_commands/find_references.rs b/crates/wrapper/src/custom_commands/find_references.rs index e273a297..9b8d4ed8 100644 --- a/crates/wrapper/src/custom_commands/find_references.rs +++ b/crates/wrapper/src/custom_commands/find_references.rs @@ -2,8 +2,8 @@ use crate::handlers::utils; use crate::manager::{LspManagerError, Manager}; use actix_web::HttpResponse; use common::api_types::{ - get_mount_dir, CodeContext, FilePosition, FileRange, GetReferencesRequest, JsonRpcRequest, - JsonRpcResponse, Position, Range, ReferencesResponse, + get_mount_dir, CodeContext, FilePosition, FileRange, FindReferencesRequest, + FindReferencesResponse, JsonRpcRequest, JsonRpcResponse, Position, Range, }; use common::utils::file_utils::uri_to_relative_path_string; use log::{error, info}; @@ -40,7 +40,7 @@ pub async fn handle(manager: &Manager, request: JsonRpcRequest) -> HttpResponse } }; - let info: GetReferencesRequest = match serde_json::from_value(params) { + let info: FindReferencesRequest = match serde_json::from_value(params) { Ok(info) => info, Err(e) => { error!("Invalid parameters for findReferences: {}", e); @@ -106,7 +106,7 @@ pub async fn handle(manager: &Manager, request: JsonRpcRequest) -> HttpResponse None }; - let response = ReferencesResponse { + let response = FindReferencesResponse { raw_response, references: references .into_iter() From 6694493f716d36c835fb35f5851e561876e8e7fb Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Thu, 27 Nov 2025 12:20:46 +0100 Subject: [PATCH 27/92] remove unused imports --- crates/proxy/src/handlers/lsp.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/crates/proxy/src/handlers/lsp.rs b/crates/proxy/src/handlers/lsp.rs index 5fda6d48..91bd281f 100644 --- a/crates/proxy/src/handlers/lsp.rs +++ b/crates/proxy/src/handlers/lsp.rs @@ -3,10 +3,7 @@ use crate::handlers::container_proxy; use crate::AppState; use actix_web::web::{Data, Json}; use actix_web::HttpResponse; -use common::api_types::{ - DefinitionsInFileRequest, FindDefinitionRequest, FindIdentifierRequest, - FindReferencedSymbolsRequest, FindReferencesRequest, JsonRpcRequest, JsonRpcResponse, -}; +use common::api_types::{JsonRpcRequest, JsonRpcResponse}; use log::{debug, error, info, warn}; use lsp_types::{ DeclarationCapability, FoldingRangeProviderCapability, HoverProviderCapability, From 4cd8bed935fb5d012eba8be0ec9c99dfdfb80069 Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Thu, 27 Nov 2025 13:30:52 +0100 Subject: [PATCH 28/92] Fix server tests and documentation --- README.md | 6 +- crates/proxy/src/handlers/read_source_code.rs | 96 -- docs/architecture.md | 38 +- docs/testing.md | 48 +- openapi.json | 834 +++--------------- scripts/start-proxy.sh | 6 +- scripts/test-all-endpoints.sh | 61 +- scripts/test-container-lifecycle.sh | 14 +- scripts/test-watchdog.sh | 2 +- 9 files changed, 240 insertions(+), 865 deletions(-) delete mode 100644 crates/proxy/src/handlers/read_source_code.rs diff --git a/README.md b/README.md index badf832e..bd498397 100644 --- a/README.md +++ b/README.md @@ -348,9 +348,9 @@ sequenceDiagram participant Wrapper as nuanced-lsp-wrapper participant Python LSP as nuanced-lsp-python
Jedi language server - Client->>+Proxy: POST /v1/symbol/find-definition
{file: "main.py", position: {line: 10, character: 5}} + Client->>+Proxy: POST /v2/lsp
lsproxy/symbol/findDefinition
{file: "main.py", position: {line: 10, character: 5}} Note over Proxy: Route to Python LSP server container based on file - Proxy->>+Wrapper: HTTP POST localhost:8080/find-definition
{file: "main.py", position: {line: 10, character: 5}} + Proxy->>+Wrapper: HTTP POST localhost:8080/lsp
lsproxy/symbol/findDefinition
{file: "main.py", position: {line: 10, character: 5}} Wrapper->>+Python LSP: LSP Request (JSON-RPC over stdio)
textDocument/definition Note over LSP: Jedi analyzes code
finds definition Python LSP-->>-Wrapper: LSP Response (JSON-RPC)
{uri, range, ...} @@ -398,7 +398,7 @@ sequenceDiagram ENABLED_LANGUAGES="python,typescript" ./scripts/start-proxy.sh /path/to/your/project # After proxy and LSP server containers are initialized, you can issue curl requests -curl http://localhost:4444/v1/system/health | jq +curl http://localhost:4444/v2/system/health | jq # Verify containers docker ps --filter "name=nuanced-lsp-" diff --git a/crates/proxy/src/handlers/read_source_code.rs b/crates/proxy/src/handlers/read_source_code.rs deleted file mode 100644 index 31ed9456..00000000 --- a/crates/proxy/src/handlers/read_source_code.rs +++ /dev/null @@ -1,96 +0,0 @@ -use crate::AppState; -use actix_web::web::{Data, Json}; -use actix_web::HttpResponse; -use log::{error, info}; -use common::api_types::{ErrorResponse, ReadSourceCodeRequest}; -use serde::Serialize; -use std::path::PathBuf; - -#[derive(Serialize)] -struct ReadSourceResponse { - source_code: String, -} - -/// Read source code from a file -#[utoipa::path( - post, - path = "/workspace/read-source-code", - tag = "file", - request_body = ReadSourceCodeRequest, - responses( - (status = 200, description = "Source code retrieved successfully"), - (status = 400, description = "Bad request"), - (status = 500, description = "Internal server error") - ) -)] -pub async fn read_source_code( - data: Data, - info: Json, -) -> HttpResponse { - info!("Received read source code request for file: {}", info.path); - - // Build full path - let file_path = PathBuf::from(&data.workspace_path).join(&info.path); - - // Security check: ensure path is within workspace - let canonical_workspace = match std::fs::canonicalize(&data.workspace_path) { - Ok(p) => p, - Err(e) => { - error!("Failed to canonicalize workspace path: {}", e); - return HttpResponse::InternalServerError().json(ErrorResponse { - error: "Invalid workspace path".to_string(), - }); - } - }; - - let canonical_file = match std::fs::canonicalize(&file_path) { - Ok(p) => p, - Err(e) => { - error!("File not found: {}", e); - return HttpResponse::NotFound().json(ErrorResponse { - error: format!("File not found: {}", info.path), - }); - } - }; - - if !canonical_file.starts_with(&canonical_workspace) { - error!("Path traversal attempt: {}", info.path); - return HttpResponse::BadRequest().json(ErrorResponse { - error: "Invalid file path".to_string(), - }); - } - - // Read the file content - match tokio::fs::read_to_string(&file_path).await { - Ok(content) => { - // If range is specified, return only that portion - if let Some(range) = &info.range { - let lines: Vec<&str> = content.lines().collect(); - let start_line = range.start.line as usize; - let end_line = range.end.line as usize; - - if start_line >= lines.len() { - return HttpResponse::BadRequest().json(ErrorResponse { - error: "Start line out of range".to_string(), - }); - } - - let end_line = end_line.min(lines.len()); - let selected_lines = &lines[start_line..end_line]; - let source_code = selected_lines.join("\n"); - - HttpResponse::Ok().json(ReadSourceResponse { source_code }) - } else { - HttpResponse::Ok().json(ReadSourceResponse { - source_code: content, - }) - } - } - Err(e) => { - error!("Failed to read file: {}", e); - HttpResponse::InternalServerError().json(ErrorResponse { - error: format!("Failed to read file: {}", e), - }) - } - } -} diff --git a/docs/architecture.md b/docs/architecture.md index f8cbe314..35b24c59 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -18,7 +18,7 @@ sequenceDiagram participant Workspace as Workspace Files
(/mnt/workspace) Note over Client,Workspace: 1. Client Request - Client->>Service: HTTP POST /v1/{language}/{endpoint}
e.g., /v1/typescript/find-definition + Client->>Service: HTTP POST /v2/lsp
e.g., /v2/lsp lsproxy/symbol/findDefinition Note over Service,Docker: 2. Service Routes Request Service->>Service: Parse request body
Validate workspace path @@ -79,7 +79,7 @@ sequenceDiagram - **Container**: `nuanced-lsp-proxy` - **Port**: 4444 (exposed to host) - **Responsibilities**: - - HTTP API gateway (exposes `/v1/{language}/{endpoint}`) + - HTTP API gateway (exposes `/v2/lsp`) - Container lifecycle management (create, monitor, remove) - Request routing to appropriate language containers - Workspace management and validation @@ -243,12 +243,12 @@ The system uses **binary injection** via Docker volumes to share the wrapper bin 1. **Client Request**: ```bash - curl -X POST http://localhost:4444/v1/typescript/find-definition \ + curl -X POST http://localhost:4444/v2/lsp \ -H "Content-Type: application/json" \ - -d '{ + -d '{"jsonrpc":"2.0","id":3,"method":"lsproxy/symbol/findDefinition","params":{ "path": "src/index.ts", "position": {"line": 10, "character": 5} - }' + }}' ``` 2. **Service Processing**: @@ -338,26 +338,26 @@ The system uses **binary injection** via Docker volumes to share the wrapper bin ## API Endpoints ### System Endpoints -- `GET /v1/system/health` - Health check +- `GET /v2/system/health` - Health check - `GET /swagger-ui/` - Swagger documentation -### Workspace Endpoints -- `POST /v1/workspace/list-files` - List all files in workspace - ### Language-Specific Endpoints -All endpoints accept POST requests with JSON body. +The LSP endpoints accept POST requests with JSON-RPC body. + +Pattern: `/v2/lsp` -Pattern: `/v1/{language}/{endpoint}` +**Workspace methods**: +- `lsproxy/workspace/listFiles` - List all files in workspace +- `lsproxy/workspace/readSourceCode` - Read file contents with optional range -**LSP-based endpoints**: -- `/v1/{language}/find-definition` - Find symbol definition -- `/v1/{language}/find-references` - Find all references -- `/v1/{language}/read-source` - Read file contents with optional range +**LSP-based methods**: +- `lsproxy/symbol/findDefinition` - Find symbol definition +- `lsproxy/symbol/findReferences` - Find all references -**ast-grep endpoints**: -- `/v1/{language}/definitions-in-file` - Get all definitions in file -- `/v1/{language}/find-identifier` - Find identifier by name -- `/v1/{language}/find-referenced-symbols` - Find symbols referenced in function body +**ast-grep methods**: +- `lsproxy/symbol/definitionsInFile` - Get all definitions in file +- `lsproxy/symbol/findIdentifier` - Find identifier by name +- `lsproxy/symbol/findReferencedSymbols` - Find symbols referenced in function body ## Testing diff --git a/docs/testing.md b/docs/testing.md index 072b21f1..9add307e 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -86,18 +86,21 @@ Tests: **Script:** `scripts/test-all-endpoints.sh` -Tests all 8 endpoints × 10 languages = 80+ tests: +Tests all lsproxy methods × 10 languages = 80+ tests: **Endpoints tested:** -1. `GET /v1/system/health` - System health check -2. `GET /v1/workspace/list-files` - List all files -3. `POST /v1/workspace/read-source-code` - Read file content -4. `POST /v1/workspace/read-source-code` (with range) - Read file range -5. `POST /v1/symbol/find-definition` - Find symbol definitions -6. `POST /v1/symbol/find-references` - Find symbol references -7. `POST /v1/symbol/find-referenced-symbols` - Find referenced symbols -8. `GET /v1/symbol/definitions-in-file` - Get all definitions in file -9. `POST /v1/symbol/find-identifier` - Find identifier by name +1. `GET /v2/system/health` - System health check +2. `POST /v2/lsp` - LSP request + +**LSP methods tested:** +1. `lsproxy/workspace/listFiles` - List all files +2. `lsproxy/workspace/readSourceCode` - Read file content +3. `lsproxy/workspace/readSourceCode` (with range) - Read file range +4. `lsproxy/symbol/findDefinition` - Find symbol definitions +5. `lsproxy/symbol/findReferences` - Find symbol references +6. `lsproxy/symbol/findReferencedSymbols` - Find referenced symbols +7. `lsproxy/symbol/definitionsInFile` - Get all definitions in file +8. `lsproxy/symbol/findIdentifier` - Find identifier by name **Languages tested:** - Python (jedi-language-server) @@ -276,26 +279,25 @@ docker exec -it nuanced-lsp-proxy /bin/bash ### Manual API Testing ```bash # Health check -curl http://localhost:4444/v1/system/health | jq +curl http://localhost:4444/v2/system/health | jq # List files -curl http://localhost:4444/v1/workspace/list-files | jq +curl -X POST http://localhost:4444/v2/lsp \ + -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":2,"method":"lsproxy/workspace/listFiles"}' \ +| jq # Read source code -curl -X POST http://localhost:4444/v1/workspace/read-source-code \ +curl -X POST http://localhost:4444/v2/lsp \ -H 'Content-Type: application/json' \ - -d '{"path":"main.py"}' | jq + -d '{"jsonrpc":"2.0","id":2,"method":"lsproxy/workspace/readSourceCode","params":{"path":"main.py"}}' \ +| jq # Find definition -curl -X POST http://localhost:4444/v1/symbol/find-definition \ +curl -X POST http://localhost:4444/v2/lsp \ -H 'Content-Type: application/json' \ - -d '{ - "position": { - "path": "main.py", - "position": {"line": 15, "character": 4} - }, - "include_source_code": false - }' | jq + -d '{"jsonrpc":"2.0","id":2,"method":"lsproxy/symbol/findDefinition","params":{"position":{"path":"main.py","position":{"line":15,"character":4}},"include_source_code":false}}' \ +| jq ``` ## Container Architecture @@ -378,7 +380,7 @@ To benchmark container startup time and API latency: time ./scripts/start-proxy.sh sample_project/all # Measure endpoint latency -time curl http://localhost:4444/v1/workspace/list-files +time curl http://localhost:4444/v2/lsp # see find-definitions example above # Measure container spawn time docker logs nuanced-lsp-proxy | grep "Container spawned" diff --git a/openapi.json b/openapi.json index 6b664795..f7cb1d15 100644 --- a/openapi.json +++ b/openapi.json @@ -1,7 +1,7 @@ { "openapi": "3.1.0", "info": { - "title": "lsproxy", + "title": "nuanced-lsp", "description": "", "license": { "name": "Apache-2.0", @@ -11,104 +11,23 @@ }, "servers": [ { - "url": "http://localhost:4444/v1", - "description": "API server v1" + "url": "http://localhost:4444/v2", + "description": "API server v2" } ], "paths": { - "/symbol/definitions-in-file": { - "get": { - "tags": [ - "symbol" - ], - "summary": "Get symbols in a specific file (uses ast-grep)", - "description": "Returns a list of symbols (functions, classes, variables, etc.) defined in the specified file.\n\nOnly the variabels defined at the file level are included.\n\nThe returned positions point to the start of the symbol's identifier.\n\ne.g. for `User` on line 0 of `src/main.py`:\n```\n0: class User:\n_________^\n1: def __init__(self, name, age):\n2: self.name = name\n3: self.age = age\n```", - "operationId": "definitions_in_file", - "parameters": [ - { - "name": "file_path", - "in": "query", - "description": "The path to the file to get the symbols for, relative to the root of the workspace.", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Symbols retrieved successfully", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Symbol" - } - } - } - } - }, - "400": { - "description": "Bad request" - }, - "500": { - "description": "Internal server error" - } - } - } - }, - "/symbol/find-definition": { - "post": { - "tags": [ - "symbol" - ], - "summary": "Get the definition of a symbol at a specific position in a file", - "description": "Returns the location of the definition for the symbol at the given position.\n\nThe input position should point inside the symbol's identifier, e.g.\n\nThe returned position points to the identifier of the symbol, and the file_path from workspace root\n\ne.g. for the definition of `User` on line 5 of `src/main.py` with the code:\n```\n0: class User:\noutput___^\n1: def __init__(self, name, age):\n2: self.name = name\n3: self.age = age\n4:\n5: user = User(\"John\", 30)\ninput_____^^^^\n```", - "operationId": "find_definition", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/GetDefinitionRequest" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Definition retrieved successfully", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DefinitionResponse" - } - } - } - }, - "400": { - "description": "Bad request" - }, - "500": { - "description": "Internal server error" - } - } - } - }, - "/symbol/find-identifier": { + "/lsp": { "post": { "tags": [ - "symbol" + "lsp" ], - "summary": "Finds occurrences of an identifier by name in a file", - "description": "Given a file path and identifier name, returns:\n- Without position: All matching identifiers in the file\n- With position: The exact identifier with that name at that position, or 3 closest identifiers with that name\n\nExample finding all occurrences of \"user_name\":\n```\nlet user_name = \"John\"; // First occurrence\nprintln!(\"{}\", user_name); // Second occurrence\n```\n\nWhen a position is provided, it searches for an exact match at that location.\nIf no exact match exists, returns the 3 identifiers closest to the position\nbased on line and character distance, prioritizing lines.", - "operationId": "find_identifier", + "summary": "Forward LSP JSON-RPC requests to language servers", + "operationId": "lsp", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/FindIdentifierRequest" + "$ref": "#/components/schemas/JsonRpcRequest" } } }, @@ -116,93 +35,17 @@ }, "responses": { "200": { - "description": "Identifier retrieved successfully", + "description": "LSP request processed successfully", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/IdentifierResponse" + "$ref": "#/components/schemas/JsonRpcResponse" } } } }, "400": { - "description": "Bad request" - }, - "500": { - "description": "Internal server error" - } - } - } - }, - "/symbol/find-referenced-symbols": { - "post": { - "tags": [ - "symbol" - ], - "summary": "Find all symbols that are referenced from a given symbol's definition", - "description": "The input position must point to a symbol (e.g. function name, class name, variable name).\nReturns all symbols referenced within that symbol's implementation, categorized into:\n- Workspace symbols (with their definitions)\n- External symbols (built-in functions like 'len', 'print' or from external libraries)\n- Symbols that couldn't be found\n\ne.g. for a function definition in `main.py`:\n```python\n@log_execution_time # Reference to decorator\ndef process_user(): # <-- Input position here\n user = User() # Reference to User class\n print(\"Done\") # Reference to built-in function\n```\nThis would return:\n- Workspace symbols: [\n log_execution_time (with definition from decorators.py),\n User (with definition from models.py)\n ]\n- External symbols: print (Python built-in)", - "operationId": "find_referenced_symbols", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/GetReferencedSymbolsRequest" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Referenced symbols retrieved successfully", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReferencedSymbolsResponse" - } - } - } - }, - "400": { - "description": "Bad request" - }, - "500": { - "description": "Internal server error" - } - } - } - }, - "/symbol/find-references": { - "post": { - "tags": [ - "symbol" - ], - "summary": "Find all references to a symbol", - "description": "The input position should point to the identifier of the symbol you want to get the references for.\n\nReturns a list of locations where the symbol at the given position is referenced.\n\nThe returned positions point to the start of the reference identifier.\n\ne.g. for `User` on line 0 of `src/main.py`:\n```\n 0: class User:\n input____^^^^\n 1: def __init__(self, name, age):\n 2: self.name = name\n 3: self.age = age\n 4:\n 5: user = User(\"John\", 30)\n output____^\n```", - "operationId": "find_references", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/GetReferencesRequest" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "References retrieved successfully", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReferencesResponse" - } - } - } - }, - "400": { - "description": "Bad request" + "description": "Bad request - invalid JSON-RPC format" }, "500": { "description": "Internal server error" @@ -216,11 +59,11 @@ "system" ], "summary": "Get health status of the LSP proxy service", - "description": "Returns the service status, version and language server availability", + "description": "Returns the service status, version and language server availability\nReturns 503 Service Unavailable while initialization is in progress", "operationId": "health_check", "responses": { "200": { - "description": "Health check successful", + "description": "Service is healthy and initialized", "content": { "application/json": { "schema": { @@ -231,75 +74,16 @@ }, "500": { "description": "Internal server error" - } - } - } - }, - "/workspace/list-files": { - "get": { - "tags": [ - "workspace" - ], - "summary": "Get a list of all files in the workspace", - "description": "Returns an array of file paths for all files in the current workspace.\n\nThis is a convenience endpoint that does not use the underlying Language Servers directly, but it does apply the same filtering.", - "operationId": "list_files", - "responses": { - "200": { - "description": "Workspace files retrieved successfully", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "string" - } - } - } - } - }, - "400": { - "description": "Bad request" }, - "500": { - "description": "Internal server error" - } - } - } - }, - "/workspace/read-source-code": { - "post": { - "tags": [ - "workspace" - ], - "summary": "Read source code from a file in the workspace", - "description": "Returns the contents of the specified file.", - "operationId": "read_source_code", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReadSourceCodeRequest" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Source code retrieved successfully", + "503": { + "description": "Service is initializing", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReadSourceCodeResponse" + "$ref": "#/components/schemas/HealthResponse" } } } - }, - "400": { - "description": "Bad request" - }, - "500": { - "description": "Internal server error" } } } @@ -307,214 +91,6 @@ }, "components": { "schemas": { - "CodeContext": { - "type": "object", - "required": [ - "range", - "source_code" - ], - "properties": { - "range": { - "$ref": "#/components/schemas/FileRange" - }, - "source_code": { - "type": "string" - } - } - }, - "DefinitionResponse": { - "type": "object", - "description": "Response to a definition request.\n\nThe definition(s) of the symbol.\nPoints to the start position of the symbol's identifier.\n\ne.g. for the definition of `User` on line 5 of `src/main.py` with the code:\n```\n0: class User:\n_________^\n1: def __init__(self, name, age):\n2: self.name = name\n3: self.age = age\n4:\n5: user = User(\"John\", 30)\n__________^\n```\nThe definition(s) will be `[{\"path\": \"src/main.py\", \"line\": 0, \"character\": 6}]`.", - "required": [ - "definitions", - "selected_identifier" - ], - "properties": { - "definitions": { - "type": "array", - "items": { - "$ref": "#/components/schemas/FilePosition" - } - }, - "raw_response": { - "description": "The raw response from the langserver.\n\nhttps://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_definition" - }, - "selected_identifier": { - "$ref": "#/components/schemas/Identifier", - "description": "The identifier that was \"clicked-on\" to get the definition." - }, - "source_code_context": { - "type": [ - "array", - "null" - ], - "items": { - "$ref": "#/components/schemas/CodeContext" - }, - "description": "The source code of symbol definitions." - } - } - }, - "ErrorResponse": { - "type": "object", - "description": "Response returned when an API error occurs", - "required": [ - "error" - ], - "properties": { - "error": { - "type": "string", - "description": "Description of the error that occurred" - } - } - }, - "FilePosition": { - "type": "object", - "description": "A position within a specific file in the workspace", - "required": [ - "path", - "position" - ], - "properties": { - "path": { - "type": "string", - "description": "Path to the file, relative to the workspace root", - "example": "src/main.py" - }, - "position": { - "$ref": "#/components/schemas/Position", - "description": "Position within the file" - } - } - }, - "FileRange": { - "type": "object", - "description": "A range within a specific file, defined by start and end positions", - "required": [ - "path", - "range" - ], - "properties": { - "path": { - "type": "string", - "description": "The path to the file.", - "example": "src/main.py" - }, - "range": { - "$ref": "#/components/schemas/Range", - "description": "The range within the file" - } - } - }, - "FileSymbolsRequest": { - "type": "object", - "description": "Request to get the symbols in a file.", - "required": [ - "file_path" - ], - "properties": { - "file_path": { - "type": "string", - "description": "The path to the file to get the symbols for, relative to the root of the workspace.", - "example": "src/main.py" - } - } - }, - "FindIdentifierRequest": { - "type": "object", - "required": [ - "name", - "path" - ], - "properties": { - "name": { - "type": "string", - "description": "The name of the identifier to search for.", - "example": "User" - }, - "path": { - "type": "string", - "description": "The path to the file to search for identifiers.", - "example": "src/main.py" - }, - "position": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/Position", - "description": "The position hint to search for identifiers. If not provided." - } - ] - } - } - }, - "GetDefinitionRequest": { - "type": "object", - "required": [ - "position" - ], - "properties": { - "include_raw_response": { - "type": "boolean", - "description": "Whether to include the raw response from the langserver in the response.\nDefaults to false.", - "example": false - }, - "include_source_code": { - "type": "boolean", - "description": "Whether to include the source code around the symbol's identifier in the response.\nDefaults to false.\nTODO: Implement this", - "example": false - }, - "position": { - "$ref": "#/components/schemas/FilePosition" - } - } - }, - "GetReferencedSymbolsRequest": { - "type": "object", - "description": "Request to get all symbols that are referenced from a symbol at the given position, either\nfocusing on function calls, or more permissively finding all references\n\nThe input position must point to a symbol (e.g. function name, class name, variable name).\nThe response will include all symbols that are referenced from that input symbol.\nFor example, if the position points to a function name, the response will include\nall symbols referenced within that function's implementation.", - "required": [ - "identifier_position" - ], - "properties": { - "full_scan": { - "type": "boolean", - "description": "Whether to use the more permissive rules to find referenced symbols. This will be not just\ncode that is executed but also things like type hints and chained indirection.\nDefaults to false.", - "example": false - }, - "identifier_position": { - "$ref": "#/components/schemas/FilePosition", - "description": "The identifier position of the symbol to find references within" - } - } - }, - "GetReferencesRequest": { - "type": "object", - "required": [ - "identifier_position" - ], - "properties": { - "identifier_position": { - "$ref": "#/components/schemas/FilePosition" - }, - "include_code_context_lines": { - "type": [ - "integer", - "null" - ], - "format": "int32", - "description": "Whether to include the source code of the symbol in the response.\nDefaults to none.", - "example": 5, - "minimum": 0 - }, - "include_raw_response": { - "type": "boolean", - "description": "Whether to include the raw response from the langserver in the response.\nDefaults to false.", - "example": false - } - } - }, "HealthResponse": { "type": "object", "description": "Response returned by the health check endpoint", @@ -531,19 +107,90 @@ "type": "boolean" }, "propertyNames": { - "type": "string", - "enum": [ - "python", - "typescript_javascript", - "rust", - "cpp", - "csharp", - "java", - "golang", - "php", - "ruby", - "ruby_sorbet" - ] + "oneOf": [ + { + "type": "string", + "enum": [ + "Python" + ] + }, + { + "type": "string", + "description": "TypeScript and JavaScript are handled by the same langserver", + "enum": [ + "TypeScriptJavaScript" + ] + }, + { + "type": "string", + "enum": [ + "Rust" + ] + }, + { + "type": "string", + "enum": [ + "CPP" + ] + }, + { + "type": "string", + "enum": [ + "CSharp" + ] + }, + { + "type": "string", + "enum": [ + "Java" + ] + }, + { + "type": "string", + "enum": [ + "Golang" + ] + }, + { + "type": "string", + "enum": [ + "PHP" + ] + }, + { + "type": "object", + "description": "Ruby with version and optional Sorbet variant", + "required": [ + "Ruby" + ], + "properties": { + "Ruby": { + "type": "object", + "description": "Ruby with version and optional Sorbet variant", + "required": [ + "version", + "variant" + ], + "properties": { + "sorbet_config_dir": { + "type": [ + "string", + "null" + ], + "description": "For Sorbet variant: the directory containing sorbet/config (relative to workspace root)\nThis is used to pass --dir to Sorbet so it can find its config in nested projects\nThis field is runtime-only and not exposed in the API schema" + }, + "variant": { + "$ref": "#/components/schemas/LanguageVariant" + }, + "version": { + "$ref": "#/components/schemas/LanguageVersion" + } + } + } + } + } + ], + "description": "Supported programming languages with optional version information\n\nThis enum represents languages that can be analyzed by the LSP proxy.\nLanguages with version detection (like Ruby) carry version information,\nwhile others use a single unversioned container." } }, "status": { @@ -556,270 +203,83 @@ } } }, - "Identifier": { + "JsonRpcError": { "type": "object", + "description": "JSON-RPC error", "required": [ - "name", - "file_range" + "code", + "message" ], "properties": { - "file_range": { - "$ref": "#/components/schemas/FileRange" - }, - "kind": { - "type": [ - "string", - "null" - ] - }, - "name": { - "type": "string" - } - } - }, - "IdentifierResponse": { - "type": "object", - "required": [ - "identifiers" - ], - "properties": { - "identifiers": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Identifier" - } - } - } - }, - "Position": { - "type": "object", - "description": "A position within a text document, using 0-based indexing", - "required": [ - "line", - "character" - ], - "properties": { - "character": { + "code": { "type": "integer", "format": "int32", - "description": "0-indexed character index within the line.", - "example": 5, - "minimum": 0 + "description": "Error code" }, - "line": { - "type": "integer", - "format": "int32", - "description": "0-indexed line number.", - "example": 10, - "minimum": 0 + "data": { + "description": "Optional additional error data" + }, + "message": { + "type": "string", + "description": "Error message" } } }, - "Range": { + "JsonRpcRequest": { "type": "object", + "description": "JSON-RPC request or notification", "required": [ - "start", - "end" + "jsonrpc", + "method" ], "properties": { - "end": { - "$ref": "#/components/schemas/Position", - "description": "The end position of the range." + "id": { + "description": "Optional request ID (for requests that expect a response)" }, - "start": { - "$ref": "#/components/schemas/Position", - "description": "The start position of the range." + "jsonrpc": { + "type": "string", + "description": "The JSON-RPC version (always \"2.0\")", + "example": "2.0" + }, + "method": { + "type": "string", + "description": "The method name", + "example": "textDocument/hover" + }, + "params": { + "description": "The method parameters (structure varies by method)" } } }, - "ReadSourceCodeRequest": { + "JsonRpcResponse": { "type": "object", + "description": "JSON-RPC response", "required": [ - "path" + "jsonrpc", + "id" ], "properties": { - "path": { - "type": "string", - "description": "Path to the file, relative to the workspace root", - "example": "src/main.py" - }, - "range": { + "error": { "oneOf": [ { "type": "null" }, { - "$ref": "#/components/schemas/Range", - "description": "Optional range within the file to read" + "$ref": "#/components/schemas/JsonRpcError", + "description": "The error (present on failure)" } ] - } - } - }, - "ReadSourceCodeResponse": { - "type": "object", - "required": [ - "source_code" - ], - "properties": { - "source_code": { - "type": "string" - } - } - }, - "ReferenceWithSymbolDefinitions": { - "type": "object", - "description": "A reference to a symbol along with its definition(s) found in the workspace\n\ne.g. for a reference to `User` in `main.py`:\n```python\nuser = User(\"John\", 30)\n_______^\n```\nThis would contain:\n- The reference location and name (\"User\" at line 0)\n- The symbol definition(s) (e.g. \"class User\" in models.py)", - "required": [ - "reference", - "definitions" - ], - "properties": { - "definitions": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Symbol" - } - }, - "reference": { - "$ref": "#/components/schemas/Identifier" - } - } - }, - "ReferencedSymbolsResponse": { - "type": "object", - "description": "Response containing symbols referenced from the requested position\n\nThe symbols are categorized into:\n- workspace_symbols: References to symbols that were found and have definitions in the workspace\n- external_symbols: References to symbols from outside the workspace (built-in functions, external libraries)\n- not_found: References where the symbol definition could not be found", - "required": [ - "workspace_symbols", - "external_symbols", - "not_found" - ], - "properties": { - "external_symbols": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Identifier" - } - }, - "not_found": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Identifier" - } - }, - "workspace_symbols": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ReferenceWithSymbolDefinitions" - } - } - } - }, - "ReferencesResponse": { - "type": "object", - "description": "Response to a references request.\n\nPoints to the start position of the symbol's identifier.\n\ne.g. for the references of `User` on line 0 character 6 of `src/main.py` with the code:\n```\n0: class User:\n1: def __init__(self, name, age):\n2: self.name = name\n3: self.age = age\n4:\n5: user = User(\"John\", 30)\n_________^\n6:\n7: print(user.name)\n```\nThe references will be `[{\"path\": \"src/main.py\", \"line\": 5, \"character\": 7}]`.", - "required": [ - "references", - "selected_identifier" - ], - "properties": { - "context": { - "type": [ - "array", - "null" - ], - "items": { - "$ref": "#/components/schemas/CodeContext" - }, - "description": "The source code around the references." - }, - "raw_response": { - "description": "The raw response from the langserver.\n\nhttps://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_references" }, - "references": { - "type": "array", - "items": { - "$ref": "#/components/schemas/FilePosition" - } + "id": { + "description": "The request ID (matches the request)" }, - "selected_identifier": { - "$ref": "#/components/schemas/Identifier", - "description": "The identifier that was \"clicked-on\" to get the references." - } - } - }, - "SupportedLanguages": { - "type": "string", - "enum": [ - "python", - "typescript_javascript", - "rust", - "cpp", - "csharp", - "java", - "golang", - "php", - "ruby" - ] - }, - "Symbol": { - "type": "object", - "required": [ - "name", - "kind", - "identifier_position", - "file_range" - ], - "properties": { - "file_range": { - "$ref": "#/components/schemas/FileRange", - "description": "The full range of the symbol." - }, - "identifier_position": { - "$ref": "#/components/schemas/FilePosition", - "description": "The start position of the symbol's identifier." - }, - "kind": { + "jsonrpc": { "type": "string", - "description": "The kind of the symbol (e.g., function, class).", - "example": "class" + "description": "The JSON-RPC version (always \"2.0\")", + "example": "2.0" }, - "name": { - "type": "string", - "description": "The name of the symbol.", - "example": "User" - } - } - }, - "Vec": { - "type": "array", - "items": { - "type": "object", - "required": [ - "name", - "kind", - "identifier_position", - "file_range" - ], - "properties": { - "file_range": { - "$ref": "#/components/schemas/FileRange", - "description": "The full range of the symbol." - }, - "identifier_position": { - "$ref": "#/components/schemas/FilePosition", - "description": "The start position of the symbol's identifier." - }, - "kind": { - "type": "string", - "description": "The kind of the symbol (e.g., function, class).", - "example": "class" - }, - "name": { - "type": "string", - "description": "The name of the symbol.", - "example": "User" - } + "result": { + "description": "The result (present on success)" } } } @@ -839,8 +299,8 @@ ], "tags": [ { - "name": "lsproxy-api", - "description": "LSP Proxy API" + "name": "nuanced-lsp-api", + "description": "Nuanced LSP API" } ] -} +} \ No newline at end of file diff --git a/scripts/start-proxy.sh b/scripts/start-proxy.sh index 04fcb0eb..83985564 100755 --- a/scripts/start-proxy.sh +++ b/scripts/start-proxy.sh @@ -157,8 +157,8 @@ if [ "$DETACHED" = true ]; then # Show info echo -e "${BLUE}Service Information:${NC}" echo -e " Container: nuanced-lsp-proxy" - echo -e " URL: http://localhost:${PORT}/v1" - echo -e " Health: http://localhost:${PORT}/v1/system/health" + echo -e " URL: http://localhost:${PORT}/v2" + echo -e " Health: http://localhost:${PORT}/v2/system/health" echo -e " Swagger: http://localhost:${PORT}/swagger-ui/" echo @@ -193,7 +193,7 @@ if [ "$DETACHED" = true ]; then echo -e "${BLUE}Useful commands:${NC}" echo -e " View logs: docker logs -f nuanced-lsp-proxy" echo -e " Stop service: docker rm -f nuanced-lsp-proxy" - echo -e " Test health: curl http://localhost:${PORT}/v1/system/health | jq" + echo -e " Test health: curl http://localhost:${PORT}/v2/system/health | jq" echo -e " Run tests: ./scripts/test-all-endpoints.sh" echo diff --git a/scripts/test-all-endpoints.sh b/scripts/test-all-endpoints.sh index 729b3ddc..216ef0cb 100755 --- a/scripts/test-all-endpoints.sh +++ b/scripts/test-all-endpoints.sh @@ -26,7 +26,7 @@ YELLOW='\033[1;33m' NC='\033[0m' # No Color # Configuration -BASE_URL="${BASE_URL:-http://localhost:4444/v1}" +BASE_URL="${BASE_URL:-http://localhost:4444/v2}" WORKSPACE_PATH="${1:-sample_project/all}" CLEANUP_ON_EXIT=true @@ -203,6 +203,19 @@ test_endpoint() { fi } +test_lsp_method() { + local test_name="$1" + local method="$2" + local data="$3" + local expected_status="${4:-200}" + local validation_check="$5" + if [ -n "$data" ]; then + test_endpoint "$test_name" POST /lsp "{\"jsonrpc\":\"2.0\",\"id\":\"$TOTAL_TESTS\",\"method\":\"$method\",\"params\":$data}" "$expected_status" "jq -e '.result' | $validation_check" + else + test_endpoint "$test_name" POST /lsp "{\"jsonrpc\":\"2.0\",\"id\":\"$TOTAL_TESTS\",\"method\":\"$method\"}" "$expected_status" "jq -e '.result' | $validation_check" + fi +} + # Enhanced test function for find-referenced-symbols with deep validation test_find_referenced_symbols_enhanced() { local lang="$1" @@ -219,11 +232,11 @@ test_find_referenced_symbols_enhanced() { local data="{\"identifier_position\":{\"path\":\"$file\",\"position\":{\"line\":$line,\"character\":$char}},\"full_scan\":false}" # Make request - local curl_cmd="curl -s -w '\n%{http_code}' --max-time 30 -X POST -H 'Content-Type: application/json' -d '$data' '$BASE_URL/symbol/find-referenced-symbols'" + local curl_cmd="curl -s -w '\n%{http_code}' --max-time 30 -X POST -H 'Content-Type: application/json' -d '{\"jsonrpc\":\"2.0\",\"id\":\"$TOTAL_TESTS\",\"method\":\"lsproxy/symbol/findReferencedSymbols\",\"params\":$data}' '$BASE_URL/lsp'" if response=$(eval "$curl_cmd" 2>&1); then # Split response body and status code - local body=$(echo "$response" | sed '$d') + local body=$(echo "$response" | sed '$d' | jq .result) local status=$(echo "$response" | tail -n 1) # Validate HTTP status code @@ -391,9 +404,8 @@ echo # Test 2: List Files (language-agnostic) echo -e "${YELLOW}2. Workspace Endpoints (Language-Agnostic)${NC}" -test_endpoint "List Files" \ - "GET" \ - "/workspace/list-files" \ +test_lsp_method "List Files" \ + "lsproxy/workspace/listFiles" \ "" \ "200" \ "jq -e 'type == \"array\" and length > 0' > /dev/null" @@ -421,57 +433,50 @@ while IFS='|' read -r lang test_file symbol_name symbol_line symbol_char health_ "jq -e '.languages.$health_key == true' > /dev/null" # Read Source Code - test_endpoint "Read Source ($lang)" \ - "POST" \ - "/workspace/read-source-code" \ + test_lsp_method "Read Source ($lang)" \ + "lsproxy/workspace/readSourceCode" \ "{\"path\":\"$test_file\"}" \ "200" \ "jq -e '.source_code | type == \"string\" and length > 0' > /dev/null" # Read Source Code with Range - test_endpoint "Read Source with Range ($lang)" \ - "POST" \ - "/workspace/read-source-code" \ + test_lsp_method "Read Source with Range ($lang)" \ + "lsproxy/workspace/readSourceCode" \ "{\"path\":\"$test_file\",\"range\":{\"start\":{\"line\":0,\"character\":0},\"end\":{\"line\":1,\"character\":0}}}" \ "200" \ "jq -e '.source_code | type == \"string\"' > /dev/null" # Find Definition (assert selected identifier and at least one definition) - test_endpoint "Find Definition ($lang)" \ - "POST" \ - "/symbol/find-definition" \ + test_lsp_method "Find Definition ($lang)" \ + "lsproxy/symbol/findDefinition" \ "{\"position\":{\"path\":\"$test_file\",\"position\":{\"line\":$symbol_line,\"character\":$symbol_char}},\"include_source_code\":false,\"include_raw_response\":false}" \ "200" \ "jq -e '.selected_identifier.name == \"$symbol_name\" and (.definitions | length) >= 0 and (.selected_identifier.file_range.path == \"$test_file\")' > /dev/null" # Find References (assert selected identifier matches and references is an array) - test_endpoint "Find References ($lang)" \ - "POST" \ - "/symbol/find-references" \ + test_lsp_method "Find References ($lang)" \ + "lsproxy/symbol/findReferences" \ "{\"identifier_position\":{\"path\":\"$test_file\",\"position\":{\"line\":$symbol_line,\"character\":$symbol_char}},\"include_code_context_lines\":0}" \ "200" \ "jq -e '.selected_identifier.name == \"$symbol_name\" and .selected_identifier.file_range.path == \"$test_file\" and (.references | type == \"array\")' > /dev/null" # Find Referenced Symbols - test_endpoint "Find Referenced Symbols ($lang)" \ - "POST" \ - "/symbol/find-referenced-symbols" \ + test_lsp_method "Find Referenced Symbols ($lang)" \ + "lsproxy/symbol/findReferencedSymbols" \ "{\"identifier_position\":{\"path\":\"$test_file\",\"position\":{\"line\":$symbol_line,\"character\":$symbol_char}},\"full_scan\":false}" \ "200" \ "jq -e 'type == \"object\"' > /dev/null" # Definitions in File - test_endpoint "Definitions in File ($lang)" \ - "GET" \ - "/symbol/definitions-in-file?file_path=$test_file" \ - "" \ + test_lsp_method "Definitions in File ($lang)" \ + "lsproxy/symbol/definitionsInFile" \ + "{\"file_path\":\"$test_file\"}" \ "200" \ "jq -e 'type == \"array\"' > /dev/null" # Find Identifier - test_endpoint "Find Identifier ($lang)" \ - "POST" \ - "/symbol/find-identifier" \ + test_lsp_method "Find Identifier ($lang)" \ + "lsproxy/symbol/findIdentifier" \ "{\"path\":\"$test_file\",\"name\":\"$symbol_name\"}" \ "200" \ "jq -e 'type == \"object\"' > /dev/null" diff --git a/scripts/test-container-lifecycle.sh b/scripts/test-container-lifecycle.sh index 6563ccb1..fe09350d 100755 --- a/scripts/test-container-lifecycle.sh +++ b/scripts/test-container-lifecycle.sh @@ -108,7 +108,7 @@ CONTAINERS_STARTED=true echo -e "${YELLOW}Waiting for service to initialize (up to 60s)...${NC}" ready=false for i in $(seq 1 60); do - HEALTH=$(curl -sf http://localhost:4444/v1/system/health || true) + HEALTH=$(curl -sf http://localhost:4444/v2/system/health || true) STATUS=$(echo "$HEALTH" | jq -r '.status' 2>/dev/null || echo "") LANG_PENDING=$(echo "$HEALTH" | jq -r '.languages | to_entries[]? | select(.value != true) | .key' 2>/dev/null || true) @@ -139,7 +139,7 @@ test_step "Service container running" \ # Test 4: Service health check test_step "Service health check responds" \ - "curl -sf http://localhost:4444/v1/system/health > /dev/null" + "curl -sf http://localhost:4444/v2/system/health > /dev/null" # Test 5: Verify language containers were spawned echo @@ -159,13 +159,17 @@ TESTS_RUN=$((TESTS_RUN + 1)) # Test 6: Test an actual API endpoint test_step "List files endpoint works" \ - "curl -sf http://localhost:4444/v1/workspace/list-files | jq -e 'type == \"array\"' > /dev/null" + "curl -sf -X POST http://localhost:4444/v2/lsp \ + -H 'Content-Type: application/json' \ + -d '{\"jsonrpc\":\"2.0\",\"method\":\"lsproxy/workspace/listFiles\",\"id\":1}' \ + | jq -e '.result | type == \"array\"' > /dev/null" # Test 7: Test language-specific endpoint test_step "Python language works" \ - "curl -sf -X POST http://localhost:4444/v1/workspace/read-source-code \ + "curl -sf -X POST http://localhost:4444/v2/lsp \ -H 'Content-Type: application/json' \ - -d '{\"path\":\"main.py\"}' | jq -e '.source_code | length > 0' > /dev/null" + -d '{\"jsonrpc\":\"2.0\",\"method\":\"lsproxy/workspace/readSourceCode\",\"id\":2,\"params\":{\"path\":\"main.py\"}}' \ + | jq -e '.result.source_code | length > 0' > /dev/null" # Test 8: Stop service and verify cleanup echo diff --git a/scripts/test-watchdog.sh b/scripts/test-watchdog.sh index 5c5e0d2a..3488ed08 100755 --- a/scripts/test-watchdog.sh +++ b/scripts/test-watchdog.sh @@ -55,7 +55,7 @@ wait_for_service_ready() { echo -e "${YELLOW}Waiting for service to initialize (up to ${timeout}s)...${NC}" local ready=false for i in $(seq 1 "$timeout"); do - HEALTH=$(curl -sf "http://localhost:${port}/v1/system/health" || true) + HEALTH=$(curl -sf "http://localhost:${port}/v2/system/health" || true) STATUS=$(echo "$HEALTH" | jq -r '.status' 2>/dev/null || echo "") LANG_PENDING=$(echo "$HEALTH" | jq -r '.languages | to_entries[]? | select(.value != true) | .key' 2>/dev/null || true) From 5de8d6410c5333c5463e3afef8367afb9f6ae836 Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Fri, 28 Nov 2025 14:48:10 +0100 Subject: [PATCH 29/92] Revert to lsproxy endpoints --- README.md | 6 +- clients/typescript/src/client.ts | 92 ++- clients/typescript/src/proxy.ts | 2 +- clients/typescript/src/types.ts | 2 +- crates/proxy/src/container/http_client.rs | 2 +- crates/proxy/src/container/orchestrator.rs | 4 +- crates/proxy/src/custom_commands/mod.rs | 29 - .../src/custom_commands/read_source_code.rs | 106 --- .../proxy/src/handlers/definitions_in_file.rs | 51 ++ crates/proxy/src/handlers/find_definition.rs | 51 ++ crates/proxy/src/handlers/find_identifier.rs | 50 ++ .../src/handlers/find_referenced_symbols.rs | 59 ++ crates/proxy/src/handlers/find_references.rs | 57 ++ .../list_files.rs | 34 +- crates/proxy/src/handlers/lsp.rs | 46 +- crates/proxy/src/handlers/mod.rs | 12 +- crates/proxy/src/handlers/read_source_code.rs | 96 +++ crates/proxy/src/lib.rs | 83 +- .../tests/container_orchestration_test.rs | 170 ++-- .../custom_commands/definitions_in_file.rs | 67 -- .../src/custom_commands/find_identifier.rs | 97 --- crates/wrapper/src/custom_commands/mod.rs | 48 -- .../src/handlers/definitions_in_file.rs | 57 ++ crates/wrapper/src/handlers/error.rs | 35 + .../find_definition.rs | 78 +- .../wrapper/src/handlers/find_identifier.rs | 97 +++ .../find_referenced_symbols.rs | 75 +- .../find_references.rs | 106 ++- crates/wrapper/src/handlers/lsp.rs | 8 - crates/wrapper/src/handlers/mod.rs | 7 + crates/wrapper/src/lib.rs | 1 - crates/wrapper/src/main.rs | 23 +- docs/architecture.md | 38 +- docs/testing.md | 48 +- openapi.json | 775 ++++++++++++++++-- scripts/start-proxy.sh | 6 +- scripts/test-all-endpoints.sh | 127 ++- scripts/test-container-lifecycle.sh | 14 +- scripts/test-watchdog.sh | 2 +- 39 files changed, 1777 insertions(+), 884 deletions(-) delete mode 100644 crates/proxy/src/custom_commands/mod.rs delete mode 100644 crates/proxy/src/custom_commands/read_source_code.rs create mode 100644 crates/proxy/src/handlers/definitions_in_file.rs create mode 100644 crates/proxy/src/handlers/find_definition.rs create mode 100644 crates/proxy/src/handlers/find_identifier.rs create mode 100644 crates/proxy/src/handlers/find_referenced_symbols.rs create mode 100644 crates/proxy/src/handlers/find_references.rs rename crates/proxy/src/{custom_commands => handlers}/list_files.rs (69%) create mode 100644 crates/proxy/src/handlers/read_source_code.rs delete mode 100644 crates/wrapper/src/custom_commands/definitions_in_file.rs delete mode 100644 crates/wrapper/src/custom_commands/find_identifier.rs delete mode 100644 crates/wrapper/src/custom_commands/mod.rs create mode 100644 crates/wrapper/src/handlers/definitions_in_file.rs create mode 100644 crates/wrapper/src/handlers/error.rs rename crates/wrapper/src/{custom_commands => handlers}/find_definition.rs (79%) create mode 100644 crates/wrapper/src/handlers/find_identifier.rs rename crates/wrapper/src/{custom_commands => handlers}/find_referenced_symbols.rs (85%) rename crates/wrapper/src/{custom_commands => handlers}/find_references.rs (71%) diff --git a/README.md b/README.md index bd498397..0c6e1a61 100644 --- a/README.md +++ b/README.md @@ -348,9 +348,9 @@ sequenceDiagram participant Wrapper as nuanced-lsp-wrapper participant Python LSP as nuanced-lsp-python
Jedi language server - Client->>+Proxy: POST /v2/lsp
lsproxy/symbol/findDefinition
{file: "main.py", position: {line: 10, character: 5}} + Client->>+Proxy: POST /v1/symbol/find-definition
{file: "main.py", position: {line: 10, character: 5}} Note over Proxy: Route to Python LSP server container based on file - Proxy->>+Wrapper: HTTP POST localhost:8080/lsp
lsproxy/symbol/findDefinition
{file: "main.py", position: {line: 10, character: 5}} + Proxy->>+Wrapper: HTTP POST localhost:8080/symbol/find-definition
{file: "main.py", position: {line: 10, character: 5}} Wrapper->>+Python LSP: LSP Request (JSON-RPC over stdio)
textDocument/definition Note over LSP: Jedi analyzes code
finds definition Python LSP-->>-Wrapper: LSP Response (JSON-RPC)
{uri, range, ...} @@ -398,7 +398,7 @@ sequenceDiagram ENABLED_LANGUAGES="python,typescript" ./scripts/start-proxy.sh /path/to/your/project # After proxy and LSP server containers are initialized, you can issue curl requests -curl http://localhost:4444/v2/system/health | jq +curl http://localhost:4444/v1/system/health | jq # Verify containers docker ps --filter "name=nuanced-lsp-" diff --git a/clients/typescript/src/client.ts b/clients/typescript/src/client.ts index c7a791f0..0205bf50 100644 --- a/clients/typescript/src/client.ts +++ b/clients/typescript/src/client.ts @@ -234,7 +234,7 @@ export class NuancedLspClient { const { httpRequestWithRetries } = await http(); return httpRequestWithRetries( "GET", - "/v2/system/health", + "/v1/system/health", url, undefined, undefined, @@ -255,11 +255,17 @@ export class NuancedLspClient { return { ok: true, data: this.fileListCache.data }; } - // Cache miss or expired - fetch from server via LSP command - const result = await this.request( - "lsproxy/workspace/listFiles", + // Cache miss or expired - fetch from server + const url = await this.lsProxyUrl(); + const { httpRequestWithRetries } = await http(); + const result = await httpRequestWithRetries( + "GET", + "/v1/workspace/list-files", + url, undefined, - timeoutSecs, + undefined, + this.retries, + this.resolveTimeout(timeoutSecs), ); // Update cache on successful response @@ -278,10 +284,16 @@ export class NuancedLspClient { range?: LspRange | null, timeoutSecs?: number, ): Promise> { - return this.request( - "lsproxy/workspace/readSourceCode", + const url = await this.lsProxyUrl(); + const { httpRequestWithRetries } = await http(); + return httpRequestWithRetries( + "POST", + "/v1/workspace/read-source-code", + url, { path: filePath, range: range ?? null }, - timeoutSecs, + undefined, + this.retries, + this.resolveTimeout(timeoutSecs), ); } @@ -291,10 +303,16 @@ export class NuancedLspClient { filePath: string, timeoutSecs?: number, ): Promise> { - return this.request( - "lsproxy/symbol/definitionsInFile", + const url = await this.lsProxyUrl(); + const { httpRequestWithRetries } = await http(); + return httpRequestWithRetries( + "GET", + "/v1/symbol/definitions-in-file", + url, + undefined, { file_path: filePath }, - timeoutSecs, + this.retries, + this.resolveTimeout(timeoutSecs), ); } @@ -304,14 +322,20 @@ export class NuancedLspClient { includeSourceCode?: boolean, timeoutSecs?: number, ): Promise> { - return this.request( - "lsproxy/symbol/findDefinition", + const url = await this.lsProxyUrl(); + const { httpRequestWithRetries } = await http(); + return httpRequestWithRetries( + "POST", + "/v1/symbol/find-definition", + url, { include_raw_response: !!includeRawResponse, include_source_code: !!includeSourceCode, position: filePosition, }, - timeoutSecs, + undefined, + this.retries, + this.resolveTimeout(timeoutSecs), ); } @@ -321,10 +345,16 @@ export class NuancedLspClient { position?: LspPosition | null, timeoutSecs?: number, ): Promise> { - return this.request( - "lsproxy/symbol/findIdentifier", + const url = await this.lsProxyUrl(); + const { httpRequestWithRetries } = await http(); + return httpRequestWithRetries( + "POST", + "/v1/symbol/find-identifier", + url, { name, path: filePath, position }, - timeoutSecs, + undefined, + this.retries, + this.resolveTimeout(timeoutSecs), ); } @@ -333,13 +363,19 @@ export class NuancedLspClient { fullScan = false, timeoutSecs?: number, ): Promise> { - return this.request( - "lsproxy/symbol/findReferencedSymbols", + const url = await this.lsProxyUrl(); + const { httpRequestWithRetries } = await http(); + return httpRequestWithRetries( + "POST", + "/v1/symbol/find-referenced-symbols", + url, { full_scan: !!fullScan, identifier_position: identifierPosition, }, - timeoutSecs, + undefined, + this.retries, + this.resolveTimeout(timeoutSecs), ); } @@ -349,14 +385,20 @@ export class NuancedLspClient { includeRawResponse?: boolean, timeoutSecs?: number, ): Promise> { - return this.request( - "lsproxy/symbol/findReferences", + const url = await this.lsProxyUrl(); + const { httpRequestWithRetries } = await http(); + return httpRequestWithRetries( + "POST", + "/v1/symbol/find-references", + url, { identifier_position: identifierPosition, include_code_context_lines: contextLines, include_raw_response: !!includeRawResponse, }, - timeoutSecs, + undefined, + this.retries, + this.resolveTimeout(timeoutSecs), ); } @@ -378,7 +420,7 @@ export class NuancedLspClient { }; const result = await httpRequestWithRetries( "POST", - "/v2/lsp", + "/lsp", url, request, undefined, @@ -416,7 +458,7 @@ export class NuancedLspClient { }; return httpRequestWithRetries( "POST", - "/v2/lsp", + "/lsp", url, request, undefined, diff --git a/clients/typescript/src/proxy.ts b/clients/typescript/src/proxy.ts index 2827902a..7bd1f891 100644 --- a/clients/typescript/src/proxy.ts +++ b/clients/typescript/src/proxy.ts @@ -342,7 +342,7 @@ export async function up( const h = await pollHttpWithRetries( "GET", - "/v2/system/health", + "/v1/system/health", base, { // Each attempt: quick timeout, tiny retry budget diff --git a/clients/typescript/src/types.ts b/clients/typescript/src/types.ts index e3471574..6a0f66fa 100644 --- a/clients/typescript/src/types.ts +++ b/clients/typescript/src/types.ts @@ -79,7 +79,7 @@ export const HttpErrSchema = z.object({ export type HttpErr = Named; export type HttpResult = Result; -// ---- Health (data-plane /v2/system/health) ---------------------------------- +// ---- Health (data-plane /v1/system/health) ---------------------------------- export const HealthResultSchema = z.object({ status: z.union([z.literal("ok"), z.literal("not ok")]), version: z.string().optional(), diff --git a/crates/proxy/src/container/http_client.rs b/crates/proxy/src/container/http_client.rs index de71be82..8c9d4d59 100644 --- a/crates/proxy/src/container/http_client.rs +++ b/crates/proxy/src/container/http_client.rs @@ -100,7 +100,7 @@ impl ContainerHttpClient { } /// Forward a raw LSP JSON-RPC request to the container - pub async fn forward_lsp_request( + pub async fn lsp( &self, request: &JsonRpcRequest, ) -> Result> { diff --git a/crates/proxy/src/container/orchestrator.rs b/crates/proxy/src/container/orchestrator.rs index e1dc41ca..e4416892 100644 --- a/crates/proxy/src/container/orchestrator.rs +++ b/crates/proxy/src/container/orchestrator.rs @@ -341,7 +341,7 @@ impl ContainerOrchestrator { Ok(info) } - /// Check if a container is healthy by polling its /health endpoint + /// Check if a container is healthy by polling its /system/health endpoint /// /// This requires the HTTP wrapper (Phase 4) to be implemented in the container. /// The health check simply verifies the wrapper is responding (simple mode). @@ -358,7 +358,7 @@ impl ContainerOrchestrator { &self, info: &ContainerInfo, ) -> Result<(), OrchestratorError> { - let health_url = format!("{}/health", info.endpoint); + let health_url = format!("{}/system/health", info.endpoint); let client = reqwest::Client::new(); log::info!("Checking health of {} at {}", info.image_name, health_url); diff --git a/crates/proxy/src/custom_commands/mod.rs b/crates/proxy/src/custom_commands/mod.rs deleted file mode 100644 index 818bcdd7..00000000 --- a/crates/proxy/src/custom_commands/mod.rs +++ /dev/null @@ -1,29 +0,0 @@ -use actix_web::HttpResponse; -use common::api_types::JsonRpcRequest; -use log::info; - -mod list_files; -mod read_source_code; - -/// Dispatch custom lsproxy commands to their respective handlers -/// -/// Returns Some(response) if the command was handled, None if it should be forwarded to containers -pub async fn handle_custom_command(request: JsonRpcRequest) -> Option { - let method = &request.method; - - let response = match method.as_str() { - "lsproxy/workspace/listFiles" => { - info!("Handling custom command: {}", method); - list_files::handle(request).await - } - "lsproxy/workspace/readSourceCode" => { - info!("Handling custom command: {}", method); - read_source_code::handle(request).await - } - _ => { - return None; - } - }; - - Some(response) -} diff --git a/crates/proxy/src/custom_commands/read_source_code.rs b/crates/proxy/src/custom_commands/read_source_code.rs deleted file mode 100644 index 467cbe8c..00000000 --- a/crates/proxy/src/custom_commands/read_source_code.rs +++ /dev/null @@ -1,106 +0,0 @@ -use actix_web::HttpResponse; -use common::api_types::{get_mount_dir, JsonRpcRequest, JsonRpcResponse, ReadSourceCodeRequest}; -use log::{error, info}; -use serde::Serialize; - -#[derive(Serialize)] -struct ReadSourceResponse { - source_code: String, -} - -pub async fn handle(request: JsonRpcRequest) -> HttpResponse { - let req_id = request.id.clone(); - - let params = match request.params.clone() { - Some(p) => p, - None => { - error!("Missing parameters for readSourceCode"); - let error = JsonRpcResponse::new_error(req_id, -32602, "Missing params"); - return HttpResponse::BadRequest().json(error); - } - }; - - let info: ReadSourceCodeRequest = match serde_json::from_value(params) { - Ok(info) => info, - Err(e) => { - error!("Invalid parameters for readSourceCode: {}", e); - let error = - JsonRpcResponse::new_error(req_id, -32602, format!("Invalid params: {}", e)); - return HttpResponse::BadRequest().json(error); - } - }; - - info!("Received read source code request for file: {}", info.path); - - // Build full path - let workspace_path = get_mount_dir(); - let file_path = workspace_path.join(&info.path); - - // Security check: ensure path is within workspace - let canonical_workspace = match std::fs::canonicalize(&workspace_path) { - Ok(p) => p, - Err(e) => { - error!("Failed to canonicalize workspace path: {}", e); - let error = - JsonRpcResponse::new_error(req_id, -32603, "Invalid workspace path".to_string()); - return HttpResponse::InternalServerError().json(error); - } - }; - - let canonical_file = match std::fs::canonicalize(&file_path) { - Ok(p) => p, - Err(e) => { - error!("File not found: {}", e); - let error = JsonRpcResponse::new_error( - req_id, - -32602, - format!("File not found: {}", info.path), - ); - return HttpResponse::NotFound().json(error); - } - }; - - if !canonical_file.starts_with(&canonical_workspace) { - error!("Path traversal attempt: {}", info.path); - let error = JsonRpcResponse::new_error(req_id, -32602, "Invalid file path".to_string()); - return HttpResponse::BadRequest().json(error); - } - - // Read the file content - match tokio::fs::read_to_string(&file_path).await { - Ok(content) => { - // If range is specified, return only that portion - let source_code = if let Some(range) = &info.range { - let lines: Vec<&str> = content.lines().collect(); - let start_line = range.start.line as usize; - let end_line = range.end.line as usize; - - if start_line >= lines.len() { - let error = JsonRpcResponse::new_error( - req_id, - -32602, - "Start line out of range".to_string(), - ); - return HttpResponse::BadRequest().json(error); - } - - let end_line = end_line.min(lines.len()); - let selected_lines = &lines[start_line..end_line]; - selected_lines.join("\n") - } else { - content - }; - - let response = ReadSourceResponse { source_code }; - let json_rpc_response = JsonRpcResponse::new_result(req_id, response); - - HttpResponse::Ok().json(json_rpc_response) - } - Err(e) => { - error!("Failed to read file: {}", e); - let error = - JsonRpcResponse::new_error(req_id, -32603, format!("Failed to read file: {}", e)); - HttpResponse::InternalServerError().json(error) - } - } -} diff --git a/crates/proxy/src/handlers/definitions_in_file.rs b/crates/proxy/src/handlers/definitions_in_file.rs new file mode 100644 index 00000000..d16c8af6 --- /dev/null +++ b/crates/proxy/src/handlers/definitions_in_file.rs @@ -0,0 +1,51 @@ +use crate::handlers::container_proxy; +use crate::AppState; +use actix_web::web::{Data, Query}; +use actix_web::HttpResponse; +use common::api_types::{DefinitionsInFileRequest, ErrorResponse, Symbol}; +use log::{error, info}; + +/// Get all symbol definitions in a file +#[utoipa::path( + get, + path = "/symbol/definitions-in-file", + tag = "symbol", + params(DefinitionsInFileRequest), + responses( + (status = 200, description = "Symbols retrieved successfully", body = Vec), + (status = 400, description = "Bad request"), + (status = 500, description = "Internal server error") + ) +)] +pub async fn definitions_in_file( + data: Data, + info: Query, +) -> HttpResponse { + info!( + "Received definitions in file request for file: {}", + info.file_path + ); + + // Get container client for this file's language + let client = + match container_proxy::get_client_for_file(&data.orchestrator, &info.file_path).await { + Ok(client) => client, + Err(e) => { + error!("Failed to get container client: {}", e); + return HttpResponse::InternalServerError().json(ErrorResponse { + error: format!("Failed to get container client: {}", e), + }); + } + }; + + // Forward request to container + match client.definitions_in_file(&info.into_inner()).await { + Ok(symbols) => HttpResponse::Ok().json(symbols), + Err(e) => { + error!("Container request failed: {}", e); + HttpResponse::InternalServerError().json(ErrorResponse { + error: format!("Container request failed: {}", e), + }) + } + } +} diff --git a/crates/proxy/src/handlers/find_definition.rs b/crates/proxy/src/handlers/find_definition.rs new file mode 100644 index 00000000..91648da8 --- /dev/null +++ b/crates/proxy/src/handlers/find_definition.rs @@ -0,0 +1,51 @@ +use crate::handlers::container_proxy; +use crate::AppState; +use actix_web::web::{Data, Json}; +use actix_web::HttpResponse; +use common::api_types::{ErrorResponse, FindDefinitionRequest, FindDefinitionResponse}; +use log::{error, info}; + +/// Get the definition of a symbol at a specific position in a file +#[utoipa::path( + post, + path = "/symbol/find-definition", + tag = "symbol", + request_body = FindDefinitionRequest, + responses( + (status = 200, description = "Definition retrieved successfully", body = FindDefinitionResponse), + (status = 400, description = "Bad request"), + (status = 500, description = "Internal server error") + ) +)] +pub async fn find_definition( + data: Data, + info: Json, +) -> HttpResponse { + info!( + "Received definition request for file: {}, line: {}, character: {}", + info.position.path, info.position.position.line, info.position.position.character + ); + + // Get container client for this file's language + let client = + match container_proxy::get_client_for_file(&data.orchestrator, &info.position.path).await { + Ok(client) => client, + Err(e) => { + error!("Failed to get container client: {}", e); + return HttpResponse::InternalServerError().json(ErrorResponse { + error: format!("Failed to get container client: {}", e), + }); + } + }; + + // Forward request to container + match client.find_definition(&info.into_inner()).await { + Ok(response) => HttpResponse::Ok().json(response), + Err(e) => { + error!("Container request failed: {}", e); + HttpResponse::InternalServerError().json(ErrorResponse { + error: format!("Container request failed: {}", e), + }) + } + } +} diff --git a/crates/proxy/src/handlers/find_identifier.rs b/crates/proxy/src/handlers/find_identifier.rs new file mode 100644 index 00000000..a0dfd5fa --- /dev/null +++ b/crates/proxy/src/handlers/find_identifier.rs @@ -0,0 +1,50 @@ +use crate::handlers::container_proxy; +use crate::AppState; +use actix_web::web::{Data, Json}; +use actix_web::HttpResponse; +use common::api_types::{ErrorResponse, FindIdentifierRequest, FindIdentifierResponse}; +use log::{error, info}; + +/// Find an identifier by name +#[utoipa::path( + post, + path = "/symbol/find-identifier", + tag = "symbol", + request_body = FindIdentifierRequest, + responses( + (status = 200, description = "Identifier found successfully", body = FindIdentifierResponse), + (status = 400, description = "Bad request"), + (status = 500, description = "Internal server error") + ) +)] +pub async fn find_identifier( + data: Data, + info: Json, +) -> HttpResponse { + info!( + "Received find identifier request for file: {}, name: {}", + info.path, info.name + ); + + // Get container client for this file's language + let client = match container_proxy::get_client_for_file(&data.orchestrator, &info.path).await { + Ok(client) => client, + Err(e) => { + error!("Failed to get container client: {}", e); + return HttpResponse::InternalServerError().json(ErrorResponse { + error: format!("Failed to get container client: {}", e), + }); + } + }; + + // Forward request to container + match client.find_identifier(&info.into_inner()).await { + Ok(response) => HttpResponse::Ok().json(response), + Err(e) => { + error!("Container request failed: {}", e); + HttpResponse::InternalServerError().json(ErrorResponse { + error: format!("Container request failed: {}", e), + }) + } + } +} diff --git a/crates/proxy/src/handlers/find_referenced_symbols.rs b/crates/proxy/src/handlers/find_referenced_symbols.rs new file mode 100644 index 00000000..9a81d4a5 --- /dev/null +++ b/crates/proxy/src/handlers/find_referenced_symbols.rs @@ -0,0 +1,59 @@ +use crate::handlers::container_proxy; +use crate::AppState; +use actix_web::web::{Data, Json}; +use actix_web::HttpResponse; +use common::api_types::{ + ErrorResponse, FindReferencedSymbolsRequest, FindReferencedSymbolsResponse, +}; +use log::{error, info}; + +/// Find all symbols referenced within a given symbol +#[utoipa::path( + post, + path = "/symbol/find-referenced-symbols", + tag = "symbol", + request_body = FindReferencedSymbolsRequest, + responses( + (status = 200, description = "Referenced symbols retrieved successfully", body = FindReferencedSymbolsResponse), + (status = 400, description = "Bad request"), + (status = 500, description = "Internal server error") + ) +)] +pub async fn find_referenced_symbols( + data: Data, + info: Json, +) -> HttpResponse { + info!( + "Received find referenced symbols request for file: {}, line: {}, character: {}", + info.identifier_position.path, + info.identifier_position.position.line, + info.identifier_position.position.character + ); + + // Get container client for this file's language + let client = match container_proxy::get_client_for_file( + &data.orchestrator, + &info.identifier_position.path, + ) + .await + { + Ok(client) => client, + Err(e) => { + error!("Failed to get container client: {}", e); + return HttpResponse::InternalServerError().json(ErrorResponse { + error: format!("Failed to get container client: {}", e), + }); + } + }; + + // Forward request to container + match client.find_referenced_symbols(&info.into_inner()).await { + Ok(response) => HttpResponse::Ok().json(response), + Err(e) => { + error!("Container request failed: {}", e); + HttpResponse::InternalServerError().json(ErrorResponse { + error: format!("Container request failed: {}", e), + }) + } + } +} diff --git a/crates/proxy/src/handlers/find_references.rs b/crates/proxy/src/handlers/find_references.rs new file mode 100644 index 00000000..8bdeef55 --- /dev/null +++ b/crates/proxy/src/handlers/find_references.rs @@ -0,0 +1,57 @@ +use crate::handlers::container_proxy; +use crate::AppState; +use actix_web::web::{Data, Json}; +use actix_web::HttpResponse; +use common::api_types::{ErrorResponse, FindReferencesRequest, FindReferencesResponse}; +use log::{error, info}; + +/// Get all references to a symbol +#[utoipa::path( + post, + path = "/symbol/find-references", + tag = "symbol", + request_body = FindReferencesRequest, + responses( + (status = 200, description = "References retrieved successfully", body = FindReferencesResponse), + (status = 400, description = "Bad request"), + (status = 500, description = "Internal server error") + ) +)] +pub async fn find_references( + data: Data, + info: Json, +) -> HttpResponse { + info!( + "Received references request for file: {}, line: {}, character: {}", + info.identifier_position.path, + info.identifier_position.position.line, + info.identifier_position.position.character + ); + + // Get container client for this file's language + let client = match container_proxy::get_client_for_file( + &data.orchestrator, + &info.identifier_position.path, + ) + .await + { + Ok(client) => client, + Err(e) => { + error!("Failed to get container client: {}", e); + return HttpResponse::InternalServerError().json(ErrorResponse { + error: format!("Failed to get container client: {}", e), + }); + } + }; + + // Forward request to container + match client.find_references(&info.into_inner()).await { + Ok(response) => HttpResponse::Ok().json(response), + Err(e) => { + error!("Container request failed: {}", e); + HttpResponse::InternalServerError().json(ErrorResponse { + error: format!("Container request failed: {}", e), + }) + } + } +} diff --git a/crates/proxy/src/custom_commands/list_files.rs b/crates/proxy/src/handlers/list_files.rs similarity index 69% rename from crates/proxy/src/custom_commands/list_files.rs rename to crates/proxy/src/handlers/list_files.rs index 3d701fcf..f5000335 100644 --- a/crates/proxy/src/custom_commands/list_files.rs +++ b/crates/proxy/src/handlers/list_files.rs @@ -1,23 +1,31 @@ +use crate::AppState; +use actix_web::web::Data; use actix_web::HttpResponse; -use common::api_types::{get_mount_dir, JsonRpcRequest, JsonRpcResponse}; +use ignore::WalkBuilder; use log::{error, info}; +use std::path::Path; +use std::sync::{Arc, Mutex}; -pub async fn handle(request: JsonRpcRequest) -> HttpResponse { - let req_id = request.id.clone(); - +/// List all files in the workspace +#[utoipa::path( + get, + path = "/workspace/list-files", + tag = "file", + responses( + (status = 200, description = "Files listed successfully"), + (status = 500, description = "Internal server error") + ) +)] +pub async fn list_files(data: Data) -> HttpResponse { info!("Received list files request"); - // Get workspace path from manager's mount_dir - let workspace_path = get_mount_dir(); + let workspace_path = Path::new(&data.workspace_path); // Use parallel file walking for performance on large workspaces - use ignore::WalkBuilder; - use std::sync::{Arc, Mutex}; - let files = Arc::new(Mutex::new(Vec::new())); - let workspace_path_arc = Arc::new(workspace_path.clone()); + let workspace_path_arc = Arc::new(workspace_path.to_path_buf()); - let walker = WalkBuilder::new(&workspace_path) + let walker = WalkBuilder::new(workspace_path) .hidden(false) // Include hidden files (like .ruby-version, .python-version) .git_ignore(false) // Don't filter by gitignore - list all workspace files .git_exclude(false) // Don't use git exclude rules @@ -53,7 +61,5 @@ pub async fn handle(request: JsonRpcRequest) -> HttpResponse { files.sort(); files.dedup(); - let json_rpc_response = JsonRpcResponse::new_result(req_id, files); - - HttpResponse::Ok().json(json_rpc_response) + HttpResponse::Ok().json(files) } diff --git a/crates/proxy/src/handlers/lsp.rs b/crates/proxy/src/handlers/lsp.rs index 91bd281f..aaeb8200 100644 --- a/crates/proxy/src/handlers/lsp.rs +++ b/crates/proxy/src/handlers/lsp.rs @@ -39,16 +39,11 @@ pub async fn lsp(data: Data, request: Json) -> HttpRes return response; } - // Try to handle as a custom command first - if let Some(response) = crate::custom_commands::handle_custom_command(lsp_req.clone()).await { - return response; - } - // For language feature requests, extract document URI and route to appropriate backend let document_uri = match lsp_req .params .as_ref() - .and_then(|p| extract_document_uri(&lsp_req.method, p, &data.workspace_path)) + .and_then(|p| extract_document_uri(p)) { Some(uri) => uri, None => { @@ -122,7 +117,7 @@ pub async fn lsp(data: Data, request: Json) -> HttpRes } // Forward request to container - match client.forward_lsp_request(&converted_request).await { + match client.lsp(&converted_request).await { Ok(mut response) => { info!("Received container response: id={}", response.id); debug!("Container response: {:?}", response); @@ -211,37 +206,12 @@ fn handle_lifecycle_request(request: &JsonRpcRequest) -> Option { } /// Extract document URI from JSON-RPC request parameters -fn extract_document_uri(method: &str, params: &Value, workspace_path: &str) -> Option { - match method { - "lsproxy/symbol/findDefinition" => params - .get("position") - .and_then(|p| p.get("path")) - .and_then(|p| p.as_str()) - .map(|p| format!("file://{workspace_path}/{}", p)), - "lsproxy/symbol/findReferences" => params - .get("identifier_position") - .and_then(|ip| ip.get("path")) - .and_then(|p| p.as_str()) - .map(|p| format!("file://{workspace_path}/{}", p)), - "lsproxy/symbol/definitionsInFile" => params - .get("file_path") - .and_then(|fp| fp.as_str()) - .map(|p| format!("file://{workspace_path}/{}", p)), - "lsproxy/symbol/findIdentifier" => params - .get("path") - .and_then(|p| p.as_str()) - .map(|p| format!("file://{workspace_path}/{}", p)), - "lsproxy/symbol/findReferencedSymbols" => params - .get("identifier_position") - .and_then(|ip| ip.get("path")) - .and_then(|p| p.as_str()) - .map(|p| format!("file://{workspace_path}/{}", p)), - _ => params - .get("textDocument") - .and_then(|td| td.get("uri")) - .and_then(|u| u.as_str()) - .map(|uri| uri.to_string()), - } +fn extract_document_uri(params: &Value) -> Option { + params + .get("textDocument") + .and_then(|td| td.get("uri")) + .and_then(|u| u.as_str()) + .map(|uri| uri.to_string()) } /// Recursively convert paths in JSON value from host to container diff --git a/crates/proxy/src/handlers/mod.rs b/crates/proxy/src/handlers/mod.rs index e6ade8db..eb255cdc 100644 --- a/crates/proxy/src/handlers/mod.rs +++ b/crates/proxy/src/handlers/mod.rs @@ -1,5 +1,15 @@ +mod definitions_in_file; +mod find_definition; +mod find_identifier; +mod find_referenced_symbols; +mod find_references; mod health; +mod list_files; mod lsp; +mod read_source_code; pub(crate) mod container_proxy; -pub use self::{health::*, lsp::*}; +pub use self::{ + definitions_in_file::*, find_definition::*, find_identifier::*, find_referenced_symbols::*, + find_references::*, health::*, list_files::*, lsp::*, read_source_code::*, +}; diff --git a/crates/proxy/src/handlers/read_source_code.rs b/crates/proxy/src/handlers/read_source_code.rs new file mode 100644 index 00000000..db015d6f --- /dev/null +++ b/crates/proxy/src/handlers/read_source_code.rs @@ -0,0 +1,96 @@ +use crate::AppState; +use actix_web::web::{Data, Json}; +use actix_web::HttpResponse; +use common::api_types::{ErrorResponse, ReadSourceCodeRequest}; +use log::{error, info}; +use serde::Serialize; +use std::path::PathBuf; + +#[derive(Serialize)] +struct ReadSourceResponse { + source_code: String, +} + +/// Read source code from a file +#[utoipa::path( + post, + path = "/workspace/read-source-code", + tag = "file", + request_body = ReadSourceCodeRequest, + responses( + (status = 200, description = "Source code retrieved successfully"), + (status = 400, description = "Bad request"), + (status = 500, description = "Internal server error") + ) +)] +pub async fn read_source_code( + data: Data, + info: Json, +) -> HttpResponse { + info!("Received read source code request for file: {}", info.path); + + // Build full path + let file_path = PathBuf::from(&data.workspace_path).join(&info.path); + + // Security check: ensure path is within workspace + let canonical_workspace = match std::fs::canonicalize(&data.workspace_path) { + Ok(p) => p, + Err(e) => { + error!("Failed to canonicalize workspace path: {}", e); + return HttpResponse::InternalServerError().json(ErrorResponse { + error: "Invalid workspace path".to_string(), + }); + } + }; + + let canonical_file = match std::fs::canonicalize(&file_path) { + Ok(p) => p, + Err(e) => { + error!("File not found: {}", e); + return HttpResponse::NotFound().json(ErrorResponse { + error: format!("File not found: {}", info.path), + }); + } + }; + + if !canonical_file.starts_with(&canonical_workspace) { + error!("Path traversal attempt: {}", info.path); + return HttpResponse::BadRequest().json(ErrorResponse { + error: "Invalid file path".to_string(), + }); + } + + // Read the file content + match tokio::fs::read_to_string(&file_path).await { + Ok(content) => { + // If range is specified, return only that portion + if let Some(range) = &info.range { + let lines: Vec<&str> = content.lines().collect(); + let start_line = range.start.line as usize; + let end_line = range.end.line as usize; + + if start_line >= lines.len() { + return HttpResponse::BadRequest().json(ErrorResponse { + error: "Start line out of range".to_string(), + }); + } + + let end_line = end_line.min(lines.len()); + let selected_lines = &lines[start_line..end_line]; + let source_code = selected_lines.join("\n"); + + HttpResponse::Ok().json(ReadSourceResponse { source_code }) + } else { + HttpResponse::Ok().json(ReadSourceResponse { + source_code: content, + }) + } + } + Err(e) => { + error!("Failed to read file: {}", e); + HttpResponse::InternalServerError().json(ErrorResponse { + error: format!("Failed to read file: {}", e), + }) + } + } +} diff --git a/crates/proxy/src/lib.rs b/crates/proxy/src/lib.rs index 5487990e..9cdf3125 100644 --- a/crates/proxy/src/lib.rs +++ b/crates/proxy/src/lib.rs @@ -17,15 +17,19 @@ use utoipa_swagger_ui::SwaggerUi; // Local modules pub mod container; -mod custom_commands; mod handlers; -use crate::handlers::{health_check, lsp}; +use crate::handlers::{ + definitions_in_file, find_definition, find_identifier, find_referenced_symbols, + find_references, health_check, list_files, lsp, read_source_code, +}; use common::api_types::{ - get_mount_dir, set_global_mount_dir, HealthResponse, JsonRpcError, JsonRpcRequest, - JsonRpcResponse, + get_mount_dir, set_global_mount_dir, CodeContext, DefinitionsInFileRequest, ErrorResponse, + FilePosition, FileRange, FindDefinitionRequest, FindDefinitionResponse, FindIdentifierRequest, + FindIdentifierResponse, FindReferencedSymbolsRequest, FindReferencedSymbolsResponse, + FindReferencesRequest, FindReferencesResponse, HealthResponse, Position, + ReferenceWithSymbolDefinitions, SupportedLanguages, Symbol, SymbolResponse, }; -// use common::utils::doc_utils::make_code_sample; pub fn check_mount_dir() -> std::io::Result<()> { fs::read_dir(get_mount_dir())?; @@ -47,21 +51,42 @@ pub fn check_mount_dir() -> std::io::Result<()> { ), components( schemas( + CodeContext, + DefinitionsInFileRequest, + ErrorResponse, + FilePosition, + FileRange, + FindDefinitionRequest, + FindDefinitionResponse, + FindIdentifierRequest, + FindIdentifierResponse, + FindReferencedSymbolsRequest, + FindReferencedSymbolsResponse, + FindReferencesRequest, + FindReferencesResponse, HealthResponse, - JsonRpcRequest, - JsonRpcResponse, - JsonRpcError, + Position, + ReferenceWithSymbolDefinitions, + SupportedLanguages, + Symbol, + SymbolResponse, ) ), paths( + crate::handlers::definitions_in_file, + crate::handlers::find_definition, + crate::handlers::find_identifier, + crate::handlers::find_referenced_symbols, + crate::handlers::find_references, crate::handlers::health_check, - crate::handlers::lsp, + crate::handlers::list_files, + crate::handlers::read_source_code, ), tags( (name = "nuanced-lsp-api", description = "Nuanced LSP API") ), servers( - (url = "http://localhost:4444/v2", description = "API server v2") + (url = "http://localhost:4444/v1", description = "Nuanced LSP API v1"), ) )] pub struct ApiDoc; @@ -232,10 +257,22 @@ pub async fn run_server_with_port_and_host( }; api_scope = match (path.as_str(), method) { + ("/symbol/definitions-in-file", Some(Method::Get)) => + api_scope.service(resource(path).route(get().to(definitions_in_file))), + ("/symbol/find-definition", Some(Method::Post)) => + api_scope.service(resource(path).route(post().to(find_definition))), + ("/symbol/find-identifier", Some(Method::Post)) => + api_scope.service(resource(path).route(post().to(find_identifier))), + ("/symbol/find-referenced-symbols", Some(Method::Post)) => + api_scope.service(resource(path).route(post().to(find_referenced_symbols))), + ("/symbol/find-references", Some(Method::Post)) => + api_scope.service(resource(path).route(post().to(find_references))), ("/system/health", Some(Method::Get)) => api_scope.service(resource(path).route(get().to(health_check))), - ("/lsp", Some(Method::Post)) => - api_scope.service(resource(path).route(post().to(lsp))), + ("/workspace/list-files", Some(Method::Get)) => + api_scope.service(resource(path).route(get().to(list_files))), + ("/workspace/read-source-code", Some(Method::Post)) => + api_scope.service(resource(path).route(post().to(read_source_code))), (p, m) => panic!( "Invalid path configuration for {}: {:?}. Ensure the OpenAPI spec matches your handlers.", p, @@ -249,9 +286,9 @@ pub async fn run_server_with_port_and_host( .app_data(app_state.clone()) .configure(|cfg| { if let Some(ref middleware) = jwt_middleware { - cfg.service(api_scope.wrap(middleware.clone())); + cfg.service(api_scope.wrap(middleware.clone())).route("/lsp", post().to(lsp).wrap(middleware.clone())); } else { - cfg.service(api_scope); + cfg.service(api_scope).route("/lsp", post().to(lsp)); } }) .service( @@ -264,28 +301,10 @@ pub async fn run_server_with_port_and_host( .await } -// const PYTHON_SAMPLE: &str = r#" -// import requests - -// def get_pet(pet_id: int): -// response = requests.get(f'/pets/{pet_id}') -// return response.json() -// "#; - pub fn write_openapi_to_file(file_path: &PathBuf) -> std::io::Result<()> { // We use a clone since we're just adding the docs and writing it to the file. We don't need // this for runtime let mut openapi = ApiDoc::openapi().clone(); - // if let Some(path_item) = openapi.paths.paths.get_mut("/symbol/find-definition") { - // if let Some(post_op) = &mut path_item.post { - // let mut extensions = Extensions::default(); - // extensions.insert( - // String::from("x-codeSamples"), - // serde_json::json!(vec![make_code_sample("python", PYTHON_SAMPLE),]), - // ); - // post_op.extensions = Some(extensions); - // } - // } // Create components if none exist if openapi.components.is_none() { diff --git a/crates/proxy/tests/container_orchestration_test.rs b/crates/proxy/tests/container_orchestration_test.rs index 8c40e220..34722be7 100644 --- a/crates/proxy/tests/container_orchestration_test.rs +++ b/crates/proxy/tests/container_orchestration_test.rs @@ -278,7 +278,7 @@ impl ContainerFixture { async fn wait_for_health_static() -> Result<(), Box> { let client = Client::builder().timeout(Duration::from_secs(5)).build()?; - let health_url = format!("{}/v2/system/health", BASE_URL); + let health_url = format!("{}/v1/system/health", BASE_URL); for attempt in 1..=MAX_RETRIES { match client.get(&health_url).send().await { @@ -331,7 +331,7 @@ async fn wait_for_language_health(lang_key: &str) -> Result<(), Box Result<(), Box> { let client = Client::new(); let response = client - .get(&format!("{}/v2/system/health", BASE_URL)) + .get(&format!("{}/v1/system/health", BASE_URL)) .send() .await?; @@ -426,19 +426,14 @@ async fn test_container_spawn_on_request() -> Result<(), Box Result<(), Box> { // Test find-definition endpoint let response = client - .post(&format!("{}/v2/lsp", BASE_URL)) + .post(&format!("{}/v1/symbol/find-definition", BASE_URL)) .json(&json!({ - "jsonrpc": "2.0", - "id": 1, - "method": "lsproxy/symbol/findDefinition", - "params": { - "position": { - "path": "test.py", - "position": {"line": 0, "character": 4} - }, - "include_source_code": false, - "include_raw_response": false - } + "position": { + "path": "test.py", + "position": {"line": 0, "character": 4} + }, + "include_source_code": false, + "include_raw_response": false })) .send() .await?; @@ -505,11 +495,8 @@ async fn test_request_forwarding() -> Result<(), Box> { ); let body: serde_json::Value = response.json().await?; - // Should have result.definitions field (even if empty) - assert!(body - .get("result") - .and_then(|r| r.get("definitions")) - .is_some()); + // Should have definitions field (even if empty) + assert!(body.get("definitions").is_some()); Ok(()) } @@ -526,19 +513,14 @@ async fn test_multiple_requests_same_container() -> Result<(), Box Result<(), Box Result<(), Box> { let client = Client::new(); let response = client - .post(&format!("{}/v2/lsp", BASE_URL)) - .json(&json!({ - "jsonrpc": "2.0", - "id": 1, - "method": "lsproxy/workspace/listFiles", - "params": {} - })) + .get(&format!("{}/v1/workspace/list-files", BASE_URL)) .send() .await?; @@ -627,9 +598,8 @@ async fn test_list_files() -> Result<(), Box> { ); let body: serde_json::Value = response.json().await?; - // The result should be a direct array of filenames - assert!(body.get("result").is_some()); - assert!(body.get("result").unwrap().is_array()); + // The endpoint returns a direct array of filenames, not an object with a "files" field + assert!(body.is_array()); Ok(()) } @@ -644,18 +614,13 @@ async fn test_find_references() -> Result<(), Box> { wait_for_language_health("python").await?; let response = client - .post(&format!("{}/v2/lsp", BASE_URL)) + .post(&format!("{}/v1/symbol/find-references", BASE_URL)) .json(&json!({ - "jsonrpc": "2.0", - "id": 1, - "method": "lsproxy/symbol/findReferences", - "params": { - "identifier_position": { - "path": "test.py", - "position": {"line": 0, "character": 4} - }, - "include_code_context_lines": 0 - } + "identifier_position": { + "path": "test.py", + "position": {"line": 0, "character": 4} + }, + "include_code_context_lines": 0 })) .send() .await?; @@ -668,11 +633,8 @@ async fn test_find_references() -> Result<(), Box> { ); let body: serde_json::Value = response.json().await?; - // Should have result.references field - assert!(body - .get("result") - .and_then(|r| r.get("references")) - .is_some()); + // Should have references field + assert!(body.get("references").is_some()); Ok(()) } @@ -691,18 +653,13 @@ async fn test_find_references_with_context_lines() -> Result<(), Box Result<(), Box 0, "Should find at least one reference to 'hello'" @@ -747,18 +703,13 @@ async fn test_find_references_with_context_lines() -> Result<(), Box Result<(), Box HttpResponse { - let req_id = request.id.clone(); - - let params = match request.params.clone() { - Some(p) => p, - None => { - error!("Missing parameters for definitionsInFile"); - let error = JsonRpcResponse::new_error(req_id, -32602, "Missing params"); - return HttpResponse::BadRequest().json(error); - } - }; - - let info: DefinitionsInFileRequest = match serde_json::from_value(params) { - Ok(info) => info, - Err(e) => { - error!("Invalid parameters for definitionsInFile: {}", e); - let error = - JsonRpcResponse::new_error(req_id, -32602, format!("Invalid params: {}", e)); - return HttpResponse::BadRequest().json(error); - } - }; - - info!( - "Received definitions in file request for file: {}", - info.file_path - ); - - match manager.get_definitions_in_file(&info.file_path).await { - Ok(symbols) => { - let symbol_response: Vec = symbols - .into_iter() - .filter(|s| s.rule_id != "local-variable") - .map(Symbol::from) - .collect(); - - let json_rpc_response = JsonRpcResponse::new_result(req_id, symbol_response); - - HttpResponse::Ok().json(json_rpc_response) - } - Err(e) => { - let error = - JsonRpcResponse::new_error(req_id, -32603, format!("Couldn't get symbols: {}", e)); - HttpResponse::BadRequest().json(error) - } - } -} diff --git a/crates/wrapper/src/custom_commands/find_identifier.rs b/crates/wrapper/src/custom_commands/find_identifier.rs deleted file mode 100644 index 00f1ea9a..00000000 --- a/crates/wrapper/src/custom_commands/find_identifier.rs +++ /dev/null @@ -1,97 +0,0 @@ -use crate::handlers::utils; -use crate::manager::Manager; -use actix_web::HttpResponse; -use common::api_types::{ - FilePosition, FindIdentifierRequest, FindIdentifierResponse, Identifier, JsonRpcRequest, - JsonRpcResponse, -}; -use log::{error, info}; - -/// Find an identifier by name -pub async fn handle(manager: &Manager, request: JsonRpcRequest) -> HttpResponse { - let req_id = request.id.clone(); - - let params = match request.params.clone() { - Some(p) => p, - None => { - error!("Missing parameters for findIdentifier"); - let error = JsonRpcResponse::new_error(req_id, -32602, "Missing params"); - return HttpResponse::BadRequest().json(error); - } - }; - - let info: FindIdentifierRequest = match serde_json::from_value(params) { - Ok(info) => info, - Err(e) => { - error!("Invalid parameters for findIdentifier: {}", e); - let error = - JsonRpcResponse::new_error(req_id, -32602, format!("Invalid params: {}", e)); - return HttpResponse::BadRequest().json(error); - } - }; - - info!( - "Received identifier request for file: {}, name: {}, position: {:?}", - info.path, info.name, info.position - ); - let file_identifiers = match manager.get_file_identifiers(&info.path).await { - Ok(identifiers) => identifiers, - Err(e) => { - error!("Failed to get file identifiers: {:?}", e); - let error = JsonRpcResponse::new_error( - req_id, - -32603, - format!("Failed to get file identifiers: {}", e), - ); - return HttpResponse::InternalServerError().json(error); - } - }; - - // filter identifiers by name - let name_matched_identifiers: Vec = file_identifiers - .into_iter() - .filter(|id| id.name == info.name) - .collect(); - - if name_matched_identifiers.is_empty() { - let response = FindIdentifierResponse { - identifiers: vec![], - }; - let json_rpc_response = JsonRpcResponse::new_result(req_id, response); - return HttpResponse::Ok().json(json_rpc_response); - } - - if let Some(position) = &info.position { - match utils::find_identifier_at_position( - name_matched_identifiers.clone(), - &FilePosition { - path: info.path.clone(), - position: position.clone(), - }, - ) - .await - { - Ok(identifier) => { - let response = FindIdentifierResponse { - identifiers: vec![identifier], - }; - let json_rpc_response = JsonRpcResponse::new_result(req_id, response); - HttpResponse::Ok().json(json_rpc_response) - } - Err(utils::PositionError::IdentifierNotFound { closest }) => { - // Not an error case, just closest matches - let response = FindIdentifierResponse { - identifiers: closest, - }; - let json_rpc_response = JsonRpcResponse::new_result(req_id, response); - HttpResponse::Ok().json(json_rpc_response) - } - } - } else { - let response = FindIdentifierResponse { - identifiers: name_matched_identifiers, - }; - let json_rpc_response = JsonRpcResponse::new_result(req_id, response); - HttpResponse::Ok().json(json_rpc_response) - } -} diff --git a/crates/wrapper/src/custom_commands/mod.rs b/crates/wrapper/src/custom_commands/mod.rs deleted file mode 100644 index 3df8a2ff..00000000 --- a/crates/wrapper/src/custom_commands/mod.rs +++ /dev/null @@ -1,48 +0,0 @@ -use crate::manager::Manager; -use actix_web::HttpResponse; -use common::api_types::JsonRpcRequest; -use log::info; - -mod definitions_in_file; -mod find_definition; -mod find_identifier; -mod find_referenced_symbols; -mod find_references; - -/// Dispatch custom lsproxy commands to their respective handlers -/// -/// Returns Some(response) if the command was handled, None if it should be forwarded to LSP -pub async fn handle_custom_command( - manager: &Manager, - request: JsonRpcRequest, -) -> Option { - let method = &request.method; - - let response = match method.as_str() { - "lsproxy/symbol/findDefinition" => { - info!("Handling custom command: {}", method); - find_definition::handle(manager, request).await - } - "lsproxy/symbol/findReferences" => { - info!("Handling custom command: {}", method); - find_references::handle(manager, request).await - } - "lsproxy/symbol/definitionsInFile" => { - info!("Handling custom command: {}", method); - definitions_in_file::handle(manager, request).await - } - "lsproxy/symbol/findIdentifier" => { - info!("Handling custom command: {}", method); - find_identifier::handle(manager, request).await - } - "lsproxy/symbol/findReferencedSymbols" => { - info!("Handling custom command: {}", method); - find_referenced_symbols::handle(manager, request).await - } - _ => { - return None; - } - }; - - Some(response) -} diff --git a/crates/wrapper/src/handlers/definitions_in_file.rs b/crates/wrapper/src/handlers/definitions_in_file.rs new file mode 100644 index 00000000..98b3da84 --- /dev/null +++ b/crates/wrapper/src/handlers/definitions_in_file.rs @@ -0,0 +1,57 @@ +use actix_web::web::{Data, Query}; +use actix_web::HttpResponse; +use log::info; + +use crate::AppState; +use common::api_types::{DefinitionsInFileRequest, ErrorResponse, Symbol}; + +/// Get symbols in a specific file (uses ast-grep) +/// +/// Returns a list of symbols (functions, classes, variables, etc.) defined in the specified file. +/// +/// Only the variabels defined at the file level are included. +/// +/// The returned positions point to the start of the symbol's identifier. +/// +/// e.g. for `User` on line 0 of `src/main.py`: +/// ```text +/// 0: class User: +/// _________^ +/// 1: def __init__(self, name, age): +/// 2: self.name = name +/// 3: self.age = age +/// ``` +#[utoipa::path( + get, + path = "/symbol/definitions-in-file", + tag = "symbol", + params(DefinitionsInFileRequest), + responses( + (status = 200, description = "Symbols retrieved successfully", body = Vec), + (status = 400, description = "Bad request"), + (status = 500, description = "Internal server error") + ) +)] +pub async fn definitions_in_file( + data: Data, + info: Query, +) -> HttpResponse { + info!( + "Received definitions in file request for file: {}", + info.file_path + ); + + match data.manager.get_definitions_in_file(&info.file_path).await { + Ok(symbols) => { + let symbol_response: Vec = symbols + .into_iter() + .filter(|s| s.rule_id != "local-variable") + .map(Symbol::from) + .collect(); + HttpResponse::Ok().json(symbol_response) + } + Err(e) => HttpResponse::BadRequest().json(ErrorResponse { + error: format!("Couldn't get symbols: {}", e), + }), + } +} diff --git a/crates/wrapper/src/handlers/error.rs b/crates/wrapper/src/handlers/error.rs new file mode 100644 index 00000000..5912f094 --- /dev/null +++ b/crates/wrapper/src/handlers/error.rs @@ -0,0 +1,35 @@ +use crate::manager::LspManagerError; +use actix_web::HttpResponse; +use common::api_types::ErrorResponse; + +pub trait IntoHttpResponse { + fn into_http_response(self) -> HttpResponse; +} + +impl IntoHttpResponse for LspManagerError { + fn into_http_response(self) -> HttpResponse { + log::error!("LSP error: {}", self); + match self { + Self::FileNotFound(path) => HttpResponse::BadRequest().json(ErrorResponse { + error: format!("File not found: {}", path), + }), + Self::LspClientNotFound(lang) => { + HttpResponse::InternalServerError().json(ErrorResponse { + error: format!("LSP client not found for {:?}", lang), + }) + } + Self::InternalError(msg) => HttpResponse::InternalServerError().json(ErrorResponse { + error: format!("Internal error: {}", msg), + }), + Self::UnsupportedFileType(path) => HttpResponse::BadRequest().json(ErrorResponse { + error: format!("Unsupported file type: {}", path), + }), + Self::NotImplemented(msg) => HttpResponse::NotImplemented().json(ErrorResponse { + error: format!("Not implemented: {}", msg), + }), + Self::NoLspClientAvailable => HttpResponse::InternalServerError().json(ErrorResponse { + error: "No LSP client available".to_string(), + }), + } + } +} diff --git a/crates/wrapper/src/custom_commands/find_definition.rs b/crates/wrapper/src/handlers/find_definition.rs similarity index 79% rename from crates/wrapper/src/custom_commands/find_definition.rs rename to crates/wrapper/src/handlers/find_definition.rs index c148883c..3d504ce0 100644 --- a/crates/wrapper/src/custom_commands/find_definition.rs +++ b/crates/wrapper/src/handlers/find_definition.rs @@ -1,14 +1,16 @@ -use crate::handlers::utils; +use crate::handlers::error::IntoHttpResponse; use crate::manager::{LspManagerError, Manager}; +use actix_web::web::{Data, Json}; use actix_web::HttpResponse; -use common::api_types::{ - CodeContext, FilePosition, FileRange, FindDefinitionRequest, FindDefinitionResponse, - JsonRpcRequest, JsonRpcResponse, Position, Range, -}; +use common::api_types::{CodeContext, FileRange, Position, Range}; use common::utils::file_utils::uri_to_relative_path_string; use log::{error, info, warn}; -use lsp_types::{GotoDefinitionResponse, Location, Position as LspPosition, Range as LspRange}; +use crate::handlers::utils; +use crate::AppState; +use common::api_types::{ErrorResponse, FilePosition}; +use common::api_types::{FindDefinitionRequest, FindDefinitionResponse}; +use lsp_types::{GotoDefinitionResponse, Location, Position as LspPosition, Range as LspRange}; /// Get the definition of a symbol at a specific position in a file /// /// Returns the location of the definition for the symbol at the given position. @@ -28,28 +30,21 @@ use lsp_types::{GotoDefinitionResponse, Location, Position as LspPosition, Range /// 5: user = User("John", 30) /// input_____^^^^ /// ``` -pub async fn handle(manager: &Manager, request: JsonRpcRequest) -> HttpResponse { - let req_id = request.id.clone(); - - let params = match request.params.clone() { - Some(p) => p, - None => { - error!("Missing parameters for findDefinition"); - let error = JsonRpcResponse::new_error(req_id, -32602, "Missing params"); - return HttpResponse::BadRequest().json(error); - } - }; - - let info: FindDefinitionRequest = match serde_json::from_value(params) { - Ok(info) => info, - Err(e) => { - error!("Invalid parameters for findDefinition: {}", e); - let error = - JsonRpcResponse::new_error(req_id, -32602, format!("Invalid params: {}", e)); - return HttpResponse::BadRequest().json(error); - } - }; - +#[utoipa::path( + post, + path = "/symbol/find-definition", + tag = "symbol", + request_body = FindDefinitionRequest, + responses( + (status = 200, description = "Definition retrieved successfully", body = FindDefinitionResponse), + (status = 400, description = "Bad request"), + (status = 500, description = "Internal server error") + ) +)] +pub async fn find_definition( + data: Data, + info: Json, +) -> HttpResponse { info!( "Received definition request for file: {}, line: {}, character: {}", info.position.path, info.position.position.line, info.position.position.character @@ -61,16 +56,13 @@ pub async fn handle(manager: &Manager, request: JsonRpcRequest) -> HttpResponse position: info.position.position.clone(), }; - let file_identifiers = match manager.get_file_identifiers(&file_position.path).await { + let file_identifiers = match data.manager.get_file_identifiers(&file_position.path).await { Ok(identifiers) => identifiers, Err(e) => { error!("Failed to get file identifiers: {:?}", e); - let error = JsonRpcResponse::new_error( - req_id, - -32603, - format!("Failed to get file identifiers: {}", e), - ); - return HttpResponse::InternalServerError().json(error); + return HttpResponse::InternalServerError().json(ErrorResponse { + error: format!("Failed to get file identifiers: {}", e), + }); } }; @@ -84,7 +76,8 @@ pub async fn handle(manager: &Manager, request: JsonRpcRequest) -> HttpResponse }; // Call LSP directly (no ast-grep for identifier detection) - let definitions = match manager + let definitions = match data + .manager .find_definition( &info.position.path, LspPosition { @@ -96,13 +89,12 @@ pub async fn handle(manager: &Manager, request: JsonRpcRequest) -> HttpResponse { Ok(definitions) => definitions, Err(e) => { - let error = JsonRpcResponse::new_error(req_id, -32603, format!("LSP error: {}", e)); - return HttpResponse::InternalServerError().json(error); + return e.into_http_response(); } }; let source_code_context = if info.include_source_code { - match fetch_definition_source_code(manager, &definitions).await { + match fetch_definition_source_code(&data.manager, &definitions).await { Ok(context) => Some(context), Err(e) => { error!("Failed to fetch definition source code: {:?}", e); @@ -113,7 +105,7 @@ pub async fn handle(manager: &Manager, request: JsonRpcRequest) -> HttpResponse None }; - let response = FindDefinitionResponse { + HttpResponse::Ok().json(FindDefinitionResponse { raw_response: if info.include_raw_response { Some(serde_json::to_value(&definitions).unwrap()) } else { @@ -138,11 +130,7 @@ pub async fn handle(manager: &Manager, request: JsonRpcRequest) -> HttpResponse }, }, }), - }; - - let json_rpc_response = JsonRpcResponse::new_result(req_id, response); - - HttpResponse::Ok().json(json_rpc_response) + }) } async fn fetch_definition_source_code( diff --git a/crates/wrapper/src/handlers/find_identifier.rs b/crates/wrapper/src/handlers/find_identifier.rs new file mode 100644 index 00000000..c05bddb1 --- /dev/null +++ b/crates/wrapper/src/handlers/find_identifier.rs @@ -0,0 +1,97 @@ +use actix_web::{ + web::{Data, Json}, + HttpResponse, +}; + +use crate::{ + handlers::utils::{self, PositionError}, + AppState, +}; + +use common::api_types::{ + ErrorResponse, FilePosition, FindIdentifierRequest, FindIdentifierResponse, Identifier, +}; +use log::{error, info}; + +/// Finds occurrences of an identifier by name in a file +/// +/// Given a file path and identifier name, returns: +/// - Without position: All matching identifiers in the file +/// - With position: The exact identifier with that name at that position, or 3 closest identifiers with that name +/// +/// Example finding all occurrences of "user_name": +/// ```text +/// let user_name = "John"; // First occurrence +/// println!("{}", user_name); // Second occurrence +/// ``` +/// +/// When a position is provided, it searches for an exact match at that location. +/// If no exact match exists, returns the 3 identifiers closest to the position +/// based on line and character distance, prioritizing lines. +#[utoipa::path( + post, + path = "/symbol/find-identifier", + tag = "symbol", + request_body = FindIdentifierRequest, + responses( + (status = 200, description = "Identifier retrieved successfully", body = FindIdentifierResponse), + (status = 400, description = "Bad request"), + (status = 500, description = "Internal server error") + ) +)] +pub async fn find_identifier( + data: Data, + info: Json, +) -> HttpResponse { + info!( + "Received identifier request for file: {}, name: {}, position: {:?}", + info.path, info.name, info.position + ); + let file_identifiers = match data.manager.get_file_identifiers(&info.path).await { + Ok(identifiers) => identifiers, + Err(e) => { + error!("Failed to get file identifiers: {:?}", e); + return HttpResponse::InternalServerError().json(ErrorResponse { + error: format!("Failed to get file identifiers: {}", e), + }); + } + }; + + // filter identifiers by name + let name_matched_identifiers: Vec = file_identifiers + .into_iter() + .filter(|id| id.name == info.name) + .collect(); + + if name_matched_identifiers.is_empty() { + return HttpResponse::Ok().json(FindIdentifierResponse { + identifiers: vec![], + }); + } + + if let Some(position) = &info.position { + match utils::find_identifier_at_position( + name_matched_identifiers.clone(), + &FilePosition { + path: info.path.clone(), + position: position.clone(), + }, + ) + .await + { + Ok(identifier) => HttpResponse::Ok().json(FindIdentifierResponse { + identifiers: vec![identifier], + }), + Err(PositionError::IdentifierNotFound { closest }) => { + // Not an error case, just closest matches + HttpResponse::Ok().json(FindIdentifierResponse { + identifiers: closest, + }) + } + } + } else { + HttpResponse::Ok().json(FindIdentifierResponse { + identifiers: name_matched_identifiers, + }) + } +} diff --git a/crates/wrapper/src/custom_commands/find_referenced_symbols.rs b/crates/wrapper/src/handlers/find_referenced_symbols.rs similarity index 85% rename from crates/wrapper/src/custom_commands/find_referenced_symbols.rs rename to crates/wrapper/src/handlers/find_referenced_symbols.rs index 2457b391..40a04eec 100644 --- a/crates/wrapper/src/custom_commands/find_referenced_symbols.rs +++ b/crates/wrapper/src/handlers/find_referenced_symbols.rs @@ -1,8 +1,9 @@ -use crate::manager::Manager; +use crate::AppState; +use actix_web::web::{Data, Json}; use actix_web::HttpResponse; use common::api_types::{ - get_mount_dir, FilePosition, FindReferencedSymbolsRequest, FindReferencedSymbolsResponse, - Identifier, JsonRpcRequest, JsonRpcResponse, Position, ReferenceWithSymbolDefinitions, + get_mount_dir, ErrorResponse, FilePosition, FindReferencedSymbolsRequest, + FindReferencedSymbolsResponse, Identifier, Position, ReferenceWithSymbolDefinitions, }; use common::utils::file_utils::uri_to_relative_path_string; use log::{error, info}; @@ -29,28 +30,21 @@ use lsp_types::{GotoDefinitionResponse, Position as LspPosition}; /// User (with definition from models.py) /// ] /// - External symbols: print (Python built-in) -pub async fn handle(manager: &Manager, request: JsonRpcRequest) -> HttpResponse { - let req_id = request.id.clone(); - - let params = match request.params.clone() { - Some(p) => p, - None => { - error!("Missing parameters for findReferencedSymbols"); - let error = JsonRpcResponse::new_error(req_id, -32602, "Missing params"); - return HttpResponse::BadRequest().json(error); - } - }; - - let info: FindReferencedSymbolsRequest = match serde_json::from_value(params) { - Ok(info) => info, - Err(e) => { - error!("Invalid parameters for findReferencedSymbols: {}", e); - let error = - JsonRpcResponse::new_error(req_id, -32602, format!("Invalid params: {}", e)); - return HttpResponse::BadRequest().json(error); - } - }; - +#[utoipa::path( + post, + path = "/symbol/find-referenced-symbols", + tag = "symbol", + request_body = FindReferencedSymbolsRequest, + responses( + (status = 200, description = "Referenced symbols retrieved successfully", body = FindReferencedSymbolsResponse), + (status = 400, description = "Bad request"), + (status = 500, description = "Internal server error") + ) +)] +pub async fn find_referenced_symbols( + data: Data, + info: Json, +) -> HttpResponse { info!( "Received referenced symbols request for file: {}, line: {}, character: {}", info.identifier_position.path, @@ -58,7 +52,8 @@ pub async fn handle(manager: &Manager, request: JsonRpcRequest) -> HttpResponse info.identifier_position.position.character ); - let referenced_ast_symbols = match manager + let referenecd_ast_symbols = match data + .manager .find_referenced_symbols( &info.identifier_position.path, LspPosition { @@ -72,17 +67,14 @@ pub async fn handle(manager: &Manager, request: JsonRpcRequest) -> HttpResponse Ok(ast_symbols) => ast_symbols, Err(e) => { error!("Failed to get referenced symbols: {:?}", e); - let error = JsonRpcResponse::new_error( - req_id, - -32603, - format!("Failed to get referenced symbols: {}", e), - ); - return HttpResponse::InternalServerError().json(error); + return HttpResponse::InternalServerError().json(ErrorResponse { + error: format!("Failed to get referenced symbols: {}", e), + }); } }; let unwrapped_definition_responses: Vec<(Identifier, Vec)> = - referenced_ast_symbols + referenecd_ast_symbols .into_iter() .map(|(ast_grep_result, definition_response)| { let definitions = match definition_response { @@ -143,7 +135,8 @@ pub async fn handle(manager: &Manager, request: JsonRpcRequest) -> HttpResponse character: def.position.character, }; - match manager + match data + .manager .get_symbol_from_position(&def.path, &def_position) .await { @@ -170,7 +163,7 @@ pub async fn handle(manager: &Manager, request: JsonRpcRequest) -> HttpResponse // Solution: Use ast-grep to get all identifiers in the file, find the one // matching by name and line number, then call get_symbol_from_position // using that identifier's position (which aligns with documentSymbol). - match manager.get_file_identifiers(&def.path).await { + match data.manager.get_file_identifiers(&def.path).await { Ok(identifiers) => { // Find the identifier on the same line as the definition with matching name if let Some(found_identifier) = identifiers.iter().find(|id| { @@ -187,7 +180,8 @@ pub async fn handle(manager: &Manager, request: JsonRpcRequest) -> HttpResponse .character, }; - if let Ok(symbol) = manager + if let Ok(symbol) = data + .manager .get_symbol_from_position(&def.path, &id_position) .await { @@ -265,13 +259,10 @@ pub async fn handle(manager: &Manager, request: JsonRpcRequest) -> HttpResponse } }); - let response = FindReferencedSymbolsResponse { + // Return the sorted response + HttpResponse::Ok().json(FindReferencedSymbolsResponse { workspace_symbols, external_symbols, not_found, - }; - - let json_rpc_response = JsonRpcResponse::new_result(req_id, response); - - HttpResponse::Ok().json(json_rpc_response) + }) } diff --git a/crates/wrapper/src/custom_commands/find_references.rs b/crates/wrapper/src/handlers/find_references.rs similarity index 71% rename from crates/wrapper/src/custom_commands/find_references.rs rename to crates/wrapper/src/handlers/find_references.rs index 9b8d4ed8..a2e280e1 100644 --- a/crates/wrapper/src/custom_commands/find_references.rs +++ b/crates/wrapper/src/handlers/find_references.rs @@ -1,13 +1,17 @@ +use actix_web::web::{Data, Json}; +use actix_web::HttpResponse; +use log::{error, info}; +use lsp_types::{Location, Position as LspPosition}; + +use crate::handlers::error::IntoHttpResponse; use crate::handlers::utils; use crate::manager::{LspManagerError, Manager}; -use actix_web::HttpResponse; +use crate::AppState; use common::api_types::{ - get_mount_dir, CodeContext, FilePosition, FileRange, FindReferencesRequest, - FindReferencesResponse, JsonRpcRequest, JsonRpcResponse, Position, Range, + get_mount_dir, CodeContext, ErrorResponse, FilePosition, FileRange, FindReferencesRequest, + FindReferencesResponse, Position, Range, }; use common::utils::file_utils::uri_to_relative_path_string; -use log::{error, info}; -use lsp_types::{Location, Position as LspPosition}; /// Find all references to a symbol /// @@ -28,28 +32,21 @@ use lsp_types::{Location, Position as LspPosition}; /// 5: user = User("John", 30) /// output____^ /// ``` -pub async fn handle(manager: &Manager, request: JsonRpcRequest) -> HttpResponse { - let req_id = request.id.clone(); - - let params = match request.params.clone() { - Some(p) => p, - None => { - error!("Missing parameters for findReferences"); - let error = JsonRpcResponse::new_error(req_id, -32602, "Missing params"); - return HttpResponse::BadRequest().json(error); - } - }; - - let info: FindReferencesRequest = match serde_json::from_value(params) { - Ok(info) => info, - Err(e) => { - error!("Invalid parameters for findReferences: {}", e); - let error = - JsonRpcResponse::new_error(req_id, -32602, format!("Invalid params: {}", e)); - return HttpResponse::BadRequest().json(error); - } - }; - +#[utoipa::path( + post, + path = "/symbol/find-references", + tag = "symbol", + request_body = FindReferencesRequest, + responses( + (status = 200, description = "References retrieved successfully", body = FindReferencesResponse), + (status = 400, description = "Bad request"), + (status = 500, description = "Internal server error") + ) +)] +pub async fn find_references( + data: Data, + info: Json, +) -> HttpResponse { info!( "Received references request for file: {}, line: {}, character: {}", info.identifier_position.path, @@ -57,19 +54,17 @@ pub async fn handle(manager: &Manager, request: JsonRpcRequest) -> HttpResponse info.identifier_position.position.character ); - let file_identifiers = match manager + let file_identifiers = match data + .manager .get_file_identifiers(&info.identifier_position.path) .await { Ok(identifiers) => identifiers, Err(e) => { error!("Failed to get file identifiers: {:?}", e); - let error = JsonRpcResponse::new_error( - req_id, - -32603, - format!("Failed to get file identifiers: {}", e), - ); - return HttpResponse::InternalServerError().json(error); + return HttpResponse::InternalServerError().json(ErrorResponse { + error: format!("Failed to get file identifiers: {}", e), + }); } }; @@ -79,18 +74,20 @@ pub async fn handle(manager: &Manager, request: JsonRpcRequest) -> HttpResponse Ok(identifier) => identifier, Err(e) => { error!("Failed to find references from position: {:?}", e); - let error = JsonRpcResponse::new_error( - req_id, - -32602, - format!("Failed to find references from position: {}", e), - ); - return HttpResponse::BadRequest().json(error); + return HttpResponse::BadRequest().json(ErrorResponse { + error: format!("Failed to find references from position: {}", e), + }); } }; - let references_result = find_and_filter_references(manager, &info.identifier_position).await; - let code_contexts_result = - get_code_contexts(manager, &references_result, info.include_code_context_lines).await; + let references_result = + find_and_filter_references(&data.manager, &info.identifier_position).await; + let code_contexts_result = get_code_contexts( + &data.manager, + &references_result, + info.include_code_context_lines, + ) + .await; match (references_result, code_contexts_result) { (Ok(references), Ok(code_contexts)) => { @@ -121,23 +118,14 @@ pub async fn handle(manager: &Manager, request: JsonRpcRequest) -> HttpResponse context: code_contexts, selected_identifier, }; - - let json_rpc_response = JsonRpcResponse::new_result(req_id, response); - - HttpResponse::Ok().json(json_rpc_response) - } - (Err(e), _) => { - let error = JsonRpcResponse::new_error(req_id, -32603, format!("LSP error: {}", e)); - HttpResponse::InternalServerError().json(error) + HttpResponse::Ok().json(response) } + (Err(e), _) => handle_lsp_error(e), (_, Err(e)) => { error!("Failed to fetch code context: {}", e); - let error = JsonRpcResponse::new_error( - req_id, - -32603, - format!("Failed to fetch code context: {}", e), - ); - HttpResponse::InternalServerError().json(error) + HttpResponse::InternalServerError().json(ErrorResponse { + error: format!("Failed to fetch code context: {}", e), + }) } } } @@ -190,6 +178,10 @@ async fn get_code_contexts( } } +fn handle_lsp_error(e: LspManagerError) -> HttpResponse { + e.into_http_response() +} + async fn fetch_code_context( manager: &Manager, references: Vec, diff --git a/crates/wrapper/src/handlers/lsp.rs b/crates/wrapper/src/handlers/lsp.rs index ac971b31..39073cd7 100644 --- a/crates/wrapper/src/handlers/lsp.rs +++ b/crates/wrapper/src/handlers/lsp.rs @@ -7,7 +7,6 @@ use log::{debug, error, info}; /// /// This endpoint provides direct access to the LSP server by forwarding /// JSON-RPC requests and returning responses with minimal processing. -/// Custom lsproxy/* commands are handled locally. #[utoipa::path( post, path = "/lsp", @@ -31,13 +30,6 @@ pub async fn lsp( ); debug!("LSP request: {:?}", &lsp_req); - // Try to handle as a custom command first - if let Some(response) = - crate::custom_commands::handle_custom_command(&app_state.manager, lsp_req.clone()).await - { - return response; - } - // Forward the request to the LSP server match app_state.manager.lsp(lsp_req).await { Ok(response) => { diff --git a/crates/wrapper/src/handlers/mod.rs b/crates/wrapper/src/handlers/mod.rs index e4089625..0386267c 100644 --- a/crates/wrapper/src/handlers/mod.rs +++ b/crates/wrapper/src/handlers/mod.rs @@ -1,2 +1,9 @@ +pub mod definitions_in_file; +pub mod error; +pub mod find_definition; +pub mod find_identifier; +pub mod find_referenced_symbols; +pub mod find_references; pub mod lsp; + pub mod utils; diff --git a/crates/wrapper/src/lib.rs b/crates/wrapper/src/lib.rs index a03fb681..78d70927 100644 --- a/crates/wrapper/src/lib.rs +++ b/crates/wrapper/src/lib.rs @@ -2,7 +2,6 @@ // Exposes modules for testing while keeping binary entry point in main.rs // Local modules -pub mod custom_commands; pub mod handlers; pub mod lsp; pub mod manager; diff --git a/crates/wrapper/src/main.rs b/crates/wrapper/src/main.rs index e3c3f80b..8f09d5bb 100644 --- a/crates/wrapper/src/main.rs +++ b/crates/wrapper/src/main.rs @@ -5,7 +5,6 @@ use std::sync::Arc; const DEFAULT_RBENV_ROOT: &str = "/opt/rbenv"; -mod custom_commands; mod handlers; mod lsp; mod manager; @@ -301,8 +300,28 @@ async fn main() -> std::io::Result<()> { HttpServer::new(move || { App::new() .app_data(app_state.clone()) - .route("/health", web::get().to(health)) .route("/lsp", web::post().to(handlers::lsp::lsp)) + .route( + "/symbol/definitions-in-file", + web::get().to(handlers::definitions_in_file::definitions_in_file), + ) + .route( + "/symbol/find-definition", + web::post().to(handlers::find_definition::find_definition), + ) + .route( + "/symbol/find-identifier", + web::post().to(handlers::find_identifier::find_identifier), + ) + .route( + "/symbol/find-referenced-symbols", + web::post().to(handlers::find_referenced_symbols::find_referenced_symbols), + ) + .route( + "/symbol/find-references", + web::post().to(handlers::find_references::find_references), + ) + .route("/system/health", web::get().to(health)) }) .bind(("0.0.0.0", args.port))? .run() diff --git a/docs/architecture.md b/docs/architecture.md index 35b24c59..7eeb2ee7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -18,7 +18,7 @@ sequenceDiagram participant Workspace as Workspace Files
(/mnt/workspace) Note over Client,Workspace: 1. Client Request - Client->>Service: HTTP POST /v2/lsp
e.g., /v2/lsp lsproxy/symbol/findDefinition + Client->>Service: HTTP POST /v1/{scope}/{endpoint}
e.g., /v1/symbol/find-definition Note over Service,Docker: 2. Service Routes Request Service->>Service: Parse request body
Validate workspace path @@ -79,7 +79,7 @@ sequenceDiagram - **Container**: `nuanced-lsp-proxy` - **Port**: 4444 (exposed to host) - **Responsibilities**: - - HTTP API gateway (exposes `/v2/lsp`) + - HTTP API gateway (exposes `/v1/{scope}/{endpoint}`) - Container lifecycle management (create, monitor, remove) - Request routing to appropriate language containers - Workspace management and validation @@ -243,12 +243,12 @@ The system uses **binary injection** via Docker volumes to share the wrapper bin 1. **Client Request**: ```bash - curl -X POST http://localhost:4444/v2/lsp \ + curl -X POST http://localhost:4444/v1/symbol/find-definition \ -H "Content-Type: application/json" \ - -d '{"jsonrpc":"2.0","id":3,"method":"lsproxy/symbol/findDefinition","params":{ + -d '{ "path": "src/index.ts", "position": {"line": 10, "character": 5} - }}' + }' ``` 2. **Service Processing**: @@ -338,26 +338,26 @@ The system uses **binary injection** via Docker volumes to share the wrapper bin ## API Endpoints ### System Endpoints -- `GET /v2/system/health` - Health check +- `GET /v1/system/health` - Health check - `GET /swagger-ui/` - Swagger documentation -### Language-Specific Endpoints -The LSP endpoints accept POST requests with JSON-RPC body. +### Workspace Endpoints +- `GET /v1/workspace/list-files` - List all files in workspace +- `POST /v1/symbol/read-source-code` - Read file contents with optional range -Pattern: `/v2/lsp` +### Language-Specific Endpoints +All endpoints accept POST requests with JSON body. -**Workspace methods**: -- `lsproxy/workspace/listFiles` - List all files in workspace -- `lsproxy/workspace/readSourceCode` - Read file contents with optional range +Pattern: `/v1/symbol/{endpoint}` -**LSP-based methods**: -- `lsproxy/symbol/findDefinition` - Find symbol definition -- `lsproxy/symbol/findReferences` - Find all references +**LSP-based endpoints**: +- `POST /v1/symbol/find-definition` - Find symbol definition +- `POST /v1/symbol/find-references` - Find all references -**ast-grep methods**: -- `lsproxy/symbol/definitionsInFile` - Get all definitions in file -- `lsproxy/symbol/findIdentifier` - Find identifier by name -- `lsproxy/symbol/findReferencedSymbols` - Find symbols referenced in function body +**ast-grep endpoints**: +- `GET /v1/symbol/definitions-in-file` - Get all definitions in file +- `POST /v1/symbol/find-identifier` - Find identifier by name +- `POST /v1/symbol/find-referenced-symbols` - Find symbols referenced in function body ## Testing diff --git a/docs/testing.md b/docs/testing.md index 9add307e..072b21f1 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -86,21 +86,18 @@ Tests: **Script:** `scripts/test-all-endpoints.sh` -Tests all lsproxy methods × 10 languages = 80+ tests: +Tests all 8 endpoints × 10 languages = 80+ tests: **Endpoints tested:** -1. `GET /v2/system/health` - System health check -2. `POST /v2/lsp` - LSP request - -**LSP methods tested:** -1. `lsproxy/workspace/listFiles` - List all files -2. `lsproxy/workspace/readSourceCode` - Read file content -3. `lsproxy/workspace/readSourceCode` (with range) - Read file range -4. `lsproxy/symbol/findDefinition` - Find symbol definitions -5. `lsproxy/symbol/findReferences` - Find symbol references -6. `lsproxy/symbol/findReferencedSymbols` - Find referenced symbols -7. `lsproxy/symbol/definitionsInFile` - Get all definitions in file -8. `lsproxy/symbol/findIdentifier` - Find identifier by name +1. `GET /v1/system/health` - System health check +2. `GET /v1/workspace/list-files` - List all files +3. `POST /v1/workspace/read-source-code` - Read file content +4. `POST /v1/workspace/read-source-code` (with range) - Read file range +5. `POST /v1/symbol/find-definition` - Find symbol definitions +6. `POST /v1/symbol/find-references` - Find symbol references +7. `POST /v1/symbol/find-referenced-symbols` - Find referenced symbols +8. `GET /v1/symbol/definitions-in-file` - Get all definitions in file +9. `POST /v1/symbol/find-identifier` - Find identifier by name **Languages tested:** - Python (jedi-language-server) @@ -279,25 +276,26 @@ docker exec -it nuanced-lsp-proxy /bin/bash ### Manual API Testing ```bash # Health check -curl http://localhost:4444/v2/system/health | jq +curl http://localhost:4444/v1/system/health | jq # List files -curl -X POST http://localhost:4444/v2/lsp \ - -H 'Content-Type: application/json' \ - -d '{"jsonrpc":"2.0","id":2,"method":"lsproxy/workspace/listFiles"}' \ -| jq +curl http://localhost:4444/v1/workspace/list-files | jq # Read source code -curl -X POST http://localhost:4444/v2/lsp \ +curl -X POST http://localhost:4444/v1/workspace/read-source-code \ -H 'Content-Type: application/json' \ - -d '{"jsonrpc":"2.0","id":2,"method":"lsproxy/workspace/readSourceCode","params":{"path":"main.py"}}' \ -| jq + -d '{"path":"main.py"}' | jq # Find definition -curl -X POST http://localhost:4444/v2/lsp \ +curl -X POST http://localhost:4444/v1/symbol/find-definition \ -H 'Content-Type: application/json' \ - -d '{"jsonrpc":"2.0","id":2,"method":"lsproxy/symbol/findDefinition","params":{"position":{"path":"main.py","position":{"line":15,"character":4}},"include_source_code":false}}' \ -| jq + -d '{ + "position": { + "path": "main.py", + "position": {"line": 15, "character": 4} + }, + "include_source_code": false + }' | jq ``` ## Container Architecture @@ -380,7 +378,7 @@ To benchmark container startup time and API latency: time ./scripts/start-proxy.sh sample_project/all # Measure endpoint latency -time curl http://localhost:4444/v2/lsp # see find-definitions example above +time curl http://localhost:4444/v1/workspace/list-files # Measure container spawn time docker logs nuanced-lsp-proxy | grep "Container spawned" diff --git a/openapi.json b/openapi.json index f7cb1d15..19bbeb81 100644 --- a/openapi.json +++ b/openapi.json @@ -11,23 +11,101 @@ }, "servers": [ { - "url": "http://localhost:4444/v2", - "description": "API server v2" + "url": "http://localhost:4444/v1", + "description": "Nuanced LSP API v1" } ], "paths": { - "/lsp": { + "/symbol/definitions-in-file": { + "get": { + "tags": [ + "symbol" + ], + "summary": "Get all symbol definitions in a file", + "operationId": "definitions_in_file", + "parameters": [ + { + "name": "file_path", + "in": "query", + "description": "The path to the file to get the symbols for, relative to the root of the workspace.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Symbols retrieved successfully", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Symbol" + } + } + } + } + }, + "400": { + "description": "Bad request" + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/symbol/find-definition": { + "post": { + "tags": [ + "symbol" + ], + "summary": "Get the definition of a symbol at a specific position in a file", + "operationId": "find_definition", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FindDefinitionRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Definition retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FindDefinitionResponse" + } + } + } + }, + "400": { + "description": "Bad request" + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/symbol/find-identifier": { "post": { "tags": [ - "lsp" + "symbol" ], - "summary": "Forward LSP JSON-RPC requests to language servers", - "operationId": "lsp", + "summary": "Find an identifier by name", + "operationId": "find_identifier", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/JsonRpcRequest" + "$ref": "#/components/schemas/FindIdentifierRequest" } } }, @@ -35,17 +113,91 @@ }, "responses": { "200": { - "description": "LSP request processed successfully", + "description": "Identifier found successfully", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/JsonRpcResponse" + "$ref": "#/components/schemas/FindIdentifierResponse" } } } }, "400": { - "description": "Bad request - invalid JSON-RPC format" + "description": "Bad request" + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/symbol/find-referenced-symbols": { + "post": { + "tags": [ + "symbol" + ], + "summary": "Find all symbols referenced within a given symbol", + "operationId": "find_referenced_symbols", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FindReferencedSymbolsRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Referenced symbols retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FindReferencedSymbolsResponse" + } + } + } + }, + "400": { + "description": "Bad request" + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/symbol/find-references": { + "post": { + "tags": [ + "symbol" + ], + "summary": "Get all references to a symbol", + "operationId": "find_references", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FindReferencesRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "References retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FindReferencesResponse" + } + } + } + }, + "400": { + "description": "Bad request" }, "500": { "description": "Internal server error" @@ -87,10 +239,341 @@ } } } + }, + "/workspace/list-files": { + "get": { + "tags": [ + "file" + ], + "summary": "List all files in the workspace", + "operationId": "list_files", + "responses": { + "200": { + "description": "Files listed successfully" + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/workspace/read-source-code": { + "post": { + "tags": [ + "file" + ], + "summary": "Read source code from a file", + "operationId": "read_source_code", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReadSourceCodeRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Source code retrieved successfully" + }, + "400": { + "description": "Bad request" + }, + "500": { + "description": "Internal server error" + } + } + } } }, "components": { "schemas": { + "CodeContext": { + "type": "object", + "required": [ + "range", + "source_code" + ], + "properties": { + "range": { + "$ref": "#/components/schemas/FileRange" + }, + "source_code": { + "type": "string" + } + } + }, + "DefinitionsInFileRequest": { + "type": "object", + "description": "Request to get the symbols in a file.", + "required": [ + "file_path" + ], + "properties": { + "file_path": { + "type": "string", + "description": "The path to the file to get the symbols for, relative to the root of the workspace.", + "example": "src/main.py" + } + } + }, + "ErrorResponse": { + "type": "object", + "description": "Response returned when an API error occurs", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string", + "description": "Description of the error that occurred" + } + } + }, + "FilePosition": { + "type": "object", + "description": "A position within a specific file in the workspace", + "required": [ + "path", + "position" + ], + "properties": { + "path": { + "type": "string", + "description": "Path to the file, relative to the workspace root", + "example": "src/main.py" + }, + "position": { + "$ref": "#/components/schemas/Position", + "description": "Position within the file" + } + } + }, + "FileRange": { + "type": "object", + "description": "A range within a specific file, defined by start and end positions", + "required": [ + "path", + "range" + ], + "properties": { + "path": { + "type": "string", + "description": "The path to the file.", + "example": "src/main.py" + }, + "range": { + "$ref": "#/components/schemas/Range", + "description": "The range within the file" + } + } + }, + "FindDefinitionRequest": { + "type": "object", + "required": [ + "position" + ], + "properties": { + "include_raw_response": { + "type": "boolean", + "description": "Whether to include the raw response from the langserver in the response.\nDefaults to false.", + "example": false + }, + "include_source_code": { + "type": "boolean", + "description": "Whether to include the source code around the symbol's identifier in the response.\nDefaults to false.\nTODO: Implement this", + "example": false + }, + "position": { + "$ref": "#/components/schemas/FilePosition" + } + } + }, + "FindDefinitionResponse": { + "type": "object", + "description": "Response to a definition request.\n\nThe definition(s) of the symbol.\nPoints to the start position of the symbol's identifier.\n\ne.g. for the definition of `User` on line 5 of `src/main.py` with the code:\n```text\n0: class User:\n_________^\n1: def __init__(self, name, age):\n2: self.name = name\n3: self.age = age\n4:\n5: user = User(\"John\", 30)\n__________^\n```\nThe definition(s) will be `[{\"path\": \"src/main.py\", \"line\": 0, \"character\": 6}]`.", + "required": [ + "definitions", + "selected_identifier" + ], + "properties": { + "definitions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FilePosition" + } + }, + "raw_response": { + "description": "The raw response from the langserver.\n\nhttps://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_definition" + }, + "selected_identifier": { + "$ref": "#/components/schemas/Identifier", + "description": "The identifier that was \"clicked-on\" to get the definition." + }, + "source_code_context": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/components/schemas/CodeContext" + }, + "description": "The source code of symbol definitions." + } + } + }, + "FindIdentifierRequest": { + "type": "object", + "required": [ + "name", + "path" + ], + "properties": { + "name": { + "type": "string", + "description": "The name of the identifier to search for.", + "example": "User" + }, + "path": { + "type": "string", + "description": "The path to the file to search for identifiers.", + "example": "src/main.py" + }, + "position": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/Position", + "description": "The position hint to search for identifiers. If not provided." + } + ] + } + } + }, + "FindIdentifierResponse": { + "type": "object", + "required": [ + "identifiers" + ], + "properties": { + "identifiers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Identifier" + } + } + } + }, + "FindReferencedSymbolsRequest": { + "type": "object", + "description": "Request to get all symbols that are referenced from a symbol at the given position, either\nfocusing on function calls, or more permissively finding all references\n\nThe input position must point to a symbol (e.g. function name, class name, variable name).\nThe response will include all symbols that are referenced from that input symbol.\nFor example, if the position points to a function name, the response will include\nall symbols referenced within that function's implementation.", + "required": [ + "identifier_position" + ], + "properties": { + "full_scan": { + "type": "boolean", + "description": "Whether to use the more permissive rules to find referenced symbols. This will be not just\ncode that is executed but also things like type hints and chained indirection.\nDefaults to false.", + "example": false + }, + "identifier_position": { + "$ref": "#/components/schemas/FilePosition", + "description": "The identifier position of the symbol to find references within" + } + } + }, + "FindReferencedSymbolsResponse": { + "type": "object", + "description": "Response containing symbols referenced from the requested position\n\nThe symbols are categorized into:\n- workspace_symbols: References to symbols that were found and have definitions in the workspace\n- external_symbols: References to symbols from outside the workspace (built-in functions, external libraries)\n- not_found: References where the symbol definition could not be found", + "required": [ + "workspace_symbols", + "external_symbols", + "not_found" + ], + "properties": { + "external_symbols": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Identifier" + } + }, + "not_found": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Identifier" + } + }, + "workspace_symbols": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ReferenceWithSymbolDefinitions" + } + } + } + }, + "FindReferencesRequest": { + "type": "object", + "required": [ + "identifier_position" + ], + "properties": { + "identifier_position": { + "$ref": "#/components/schemas/FilePosition" + }, + "include_code_context_lines": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Whether to include the source code of the symbol in the response.\nDefaults to none.", + "example": 5, + "minimum": 0 + }, + "include_raw_response": { + "type": "boolean", + "description": "Whether to include the raw response from the langserver in the response.\nDefaults to false.", + "example": false + } + } + }, + "FindReferencesResponse": { + "type": "object", + "description": "Response to a references request.\n\nPoints to the start position of the symbol's identifier.\n\ne.g. for the references of `User` on line 0 character 6 of `src/main.py` with the code:\n```text\n0: class User:\n1: def __init__(self, name, age):\n2: self.name = name\n3: self.age = age\n4:\n5: user = User(\"John\", 30)\n_________^\n6:\n7: print(user.name)\n```\nThe references will be `[{\"path\": \"src/main.py\", \"line\": 5, \"character\": 7}]`.", + "required": [ + "references", + "selected_identifier" + ], + "properties": { + "context": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/components/schemas/CodeContext" + }, + "description": "The source code around the references." + }, + "raw_response": { + "description": "The raw response from the langserver.\n\nhttps://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_references" + }, + "references": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FilePosition" + } + }, + "selected_identifier": { + "$ref": "#/components/schemas/Identifier", + "description": "The identifier that was \"clicked-on\" to get the references." + } + } + }, "HealthResponse": { "type": "object", "description": "Response returned by the health check endpoint", @@ -203,83 +686,267 @@ } } }, - "JsonRpcError": { + "Identifier": { "type": "object", - "description": "JSON-RPC error", "required": [ - "code", - "message" + "name", + "file_range" ], "properties": { - "code": { - "type": "integer", - "format": "int32", - "description": "Error code" + "file_range": { + "$ref": "#/components/schemas/FileRange" }, - "data": { - "description": "Optional additional error data" + "kind": { + "type": [ + "string", + "null" + ] }, - "message": { - "type": "string", - "description": "Error message" + "name": { + "type": "string" } } }, - "JsonRpcRequest": { + "LanguageVariant": { + "type": "string", + "description": "Language variant for languages that have multiple type system modes\n\nSome languages support different type-checking modes that require different\nlanguage servers or configurations. This enum captures those variants.", + "enum": [ + "standard", + "sorbet" + ] + }, + "LanguageVersion": { + "type": "string", + "description": "Language version string (e.g., \"3.4.4\" for Ruby, \"3.12\" for Python)\n\nThis represents the runtime/compiler version of a language, not the container image version.\nFor languages without version detection, this may be None." + }, + "Position": { "type": "object", - "description": "JSON-RPC request or notification", + "description": "A position within a text document, using 0-based indexing", "required": [ - "jsonrpc", - "method" + "line", + "character" ], "properties": { - "id": { - "description": "Optional request ID (for requests that expect a response)" - }, - "jsonrpc": { - "type": "string", - "description": "The JSON-RPC version (always \"2.0\")", - "example": "2.0" + "character": { + "type": "integer", + "format": "int32", + "description": "0-indexed character index within the line.", + "example": 5, + "minimum": 0 }, - "method": { - "type": "string", - "description": "The method name", - "example": "textDocument/hover" + "line": { + "type": "integer", + "format": "int32", + "description": "0-indexed line number.", + "example": 10, + "minimum": 0 + } + } + }, + "Range": { + "type": "object", + "required": [ + "start", + "end" + ], + "properties": { + "end": { + "$ref": "#/components/schemas/Position", + "description": "The end position of the range." }, - "params": { - "description": "The method parameters (structure varies by method)" + "start": { + "$ref": "#/components/schemas/Position", + "description": "The start position of the range." } } }, - "JsonRpcResponse": { + "ReadSourceCodeRequest": { "type": "object", - "description": "JSON-RPC response", "required": [ - "jsonrpc", - "id" + "path" ], "properties": { - "error": { + "path": { + "type": "string", + "description": "Path to the file, relative to the workspace root", + "example": "src/main.py" + }, + "range": { "oneOf": [ { "type": "null" }, { - "$ref": "#/components/schemas/JsonRpcError", - "description": "The error (present on failure)" + "$ref": "#/components/schemas/Range", + "description": "Optional range within the file to read" } ] + } + } + }, + "ReferenceWithSymbolDefinitions": { + "type": "object", + "description": "A reference to a symbol along with its definition(s) found in the workspace\n\ne.g. for a reference to `User` in `main.py`:\n```python\nuser = User(\"John\", 30)\n_______^\n```\nThis would contain:\n- The reference location and name (\"User\" at line 0)\n- The symbol definition(s) (e.g. \"class User\" in models.py)", + "required": [ + "reference", + "definitions" + ], + "properties": { + "definitions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Symbol" + } }, - "id": { - "description": "The request ID (matches the request)" + "reference": { + "$ref": "#/components/schemas/Identifier" + } + } + }, + "SupportedLanguages": { + "oneOf": [ + { + "type": "string", + "enum": [ + "Python" + ] + }, + { + "type": "string", + "description": "TypeScript and JavaScript are handled by the same langserver", + "enum": [ + "TypeScriptJavaScript" + ] + }, + { + "type": "string", + "enum": [ + "Rust" + ] + }, + { + "type": "string", + "enum": [ + "CPP" + ] + }, + { + "type": "string", + "enum": [ + "CSharp" + ] + }, + { + "type": "string", + "enum": [ + "Java" + ] + }, + { + "type": "string", + "enum": [ + "Golang" + ] + }, + { + "type": "string", + "enum": [ + "PHP" + ] }, - "jsonrpc": { + { + "type": "object", + "description": "Ruby with version and optional Sorbet variant", + "required": [ + "Ruby" + ], + "properties": { + "Ruby": { + "type": "object", + "description": "Ruby with version and optional Sorbet variant", + "required": [ + "version", + "variant" + ], + "properties": { + "sorbet_config_dir": { + "type": [ + "string", + "null" + ], + "description": "For Sorbet variant: the directory containing sorbet/config (relative to workspace root)\nThis is used to pass --dir to Sorbet so it can find its config in nested projects\nThis field is runtime-only and not exposed in the API schema" + }, + "variant": { + "$ref": "#/components/schemas/LanguageVariant" + }, + "version": { + "$ref": "#/components/schemas/LanguageVersion" + } + } + } + } + } + ], + "description": "Supported programming languages with optional version information\n\nThis enum represents languages that can be analyzed by the LSP proxy.\nLanguages with version detection (like Ruby) carry version information,\nwhile others use a single unversioned container." + }, + "Symbol": { + "type": "object", + "required": [ + "name", + "kind", + "identifier_position", + "file_range" + ], + "properties": { + "file_range": { + "$ref": "#/components/schemas/FileRange", + "description": "The full range of the symbol." + }, + "identifier_position": { + "$ref": "#/components/schemas/FilePosition", + "description": "The start position of the symbol's identifier." + }, + "kind": { "type": "string", - "description": "The JSON-RPC version (always \"2.0\")", - "example": "2.0" + "description": "The kind of the symbol (e.g., function, class).", + "example": "class" }, - "result": { - "description": "The result (present on success)" + "name": { + "type": "string", + "description": "The name of the symbol.", + "example": "User" + } + } + }, + "Vec": { + "type": "array", + "items": { + "type": "object", + "required": [ + "name", + "kind", + "identifier_position", + "file_range" + ], + "properties": { + "file_range": { + "$ref": "#/components/schemas/FileRange", + "description": "The full range of the symbol." + }, + "identifier_position": { + "$ref": "#/components/schemas/FilePosition", + "description": "The start position of the symbol's identifier." + }, + "kind": { + "type": "string", + "description": "The kind of the symbol (e.g., function, class).", + "example": "class" + }, + "name": { + "type": "string", + "description": "The name of the symbol.", + "example": "User" + } } } } diff --git a/scripts/start-proxy.sh b/scripts/start-proxy.sh index 83985564..04fcb0eb 100755 --- a/scripts/start-proxy.sh +++ b/scripts/start-proxy.sh @@ -157,8 +157,8 @@ if [ "$DETACHED" = true ]; then # Show info echo -e "${BLUE}Service Information:${NC}" echo -e " Container: nuanced-lsp-proxy" - echo -e " URL: http://localhost:${PORT}/v2" - echo -e " Health: http://localhost:${PORT}/v2/system/health" + echo -e " URL: http://localhost:${PORT}/v1" + echo -e " Health: http://localhost:${PORT}/v1/system/health" echo -e " Swagger: http://localhost:${PORT}/swagger-ui/" echo @@ -193,7 +193,7 @@ if [ "$DETACHED" = true ]; then echo -e "${BLUE}Useful commands:${NC}" echo -e " View logs: docker logs -f nuanced-lsp-proxy" echo -e " Stop service: docker rm -f nuanced-lsp-proxy" - echo -e " Test health: curl http://localhost:${PORT}/v2/system/health | jq" + echo -e " Test health: curl http://localhost:${PORT}/v1/system/health | jq" echo -e " Run tests: ./scripts/test-all-endpoints.sh" echo diff --git a/scripts/test-all-endpoints.sh b/scripts/test-all-endpoints.sh index 216ef0cb..ed2a5a48 100755 --- a/scripts/test-all-endpoints.sh +++ b/scripts/test-all-endpoints.sh @@ -26,15 +26,50 @@ YELLOW='\033[1;33m' NC='\033[0m' # No Color # Configuration -BASE_URL="${BASE_URL:-http://localhost:4444/v2}" -WORKSPACE_PATH="${1:-sample_project/all}" +BASE_URL="${BASE_URL:-http://localhost:4444}" +WORKSPACE_PATH=sample_project/all CLEANUP_ON_EXIT=true -# Parse options -if [ "$2" = "--no-cleanup" ] || [ "$1" = "--no-cleanup" ]; then - CLEANUP_ON_EXIT=false - WORKSPACE_PATH="${WORKSPACE_PATH:-sample_project/all}" -fi +# Parse arguments +positional_args=() +while [ $# -gt 0 ]; do + case "$1" in + --no-cleanup) + CLEANUP_ON_EXIT=false + shift + ;; + --) + shift + positional_args+=("$@") + shift $# + break + ;; + -*) + echo -e "${RED}Error: Unknown flag: $1${NC}" >&2 + echo -e "${YELLOW}Usage: $0 [workspace_path] [--no-cleanup]${NC}" >&2 + exit 1 + ;; + *) + positional_args+=("$1") + shift + ;; + esac +done + +# Handle positional arguments +case ${#positional_args[@]} in + 0) + # Use default WORKSPACE_PATH + ;; + 1) + WORKSPACE_PATH="${positional_args[0]}" + ;; + *) + echo -e "${RED}Error: Only one positional argument (workspace_path) is allowed${NC}" >&2 + echo -e "${YELLOW}Usage: $0 [workspace_path] [--no-cleanup]${NC}" >&2 + exit 1 + ;; +esac # Counters TOTAL_TESTS=0 @@ -44,6 +79,9 @@ FAILED_TESTS=0 # Track if we started the service (to know if we should clean it up) STARTED_SERVICE=false +# Workspace URI for testing LSP endpoint +WORKSPACE_URI="file://$(realpath "$WORKSPACE_PATH")" + # Cleanup function cleanup() { local exit_code=$? @@ -203,19 +241,6 @@ test_endpoint() { fi } -test_lsp_method() { - local test_name="$1" - local method="$2" - local data="$3" - local expected_status="${4:-200}" - local validation_check="$5" - if [ -n "$data" ]; then - test_endpoint "$test_name" POST /lsp "{\"jsonrpc\":\"2.0\",\"id\":\"$TOTAL_TESTS\",\"method\":\"$method\",\"params\":$data}" "$expected_status" "jq -e '.result' | $validation_check" - else - test_endpoint "$test_name" POST /lsp "{\"jsonrpc\":\"2.0\",\"id\":\"$TOTAL_TESTS\",\"method\":\"$method\"}" "$expected_status" "jq -e '.result' | $validation_check" - fi -} - # Enhanced test function for find-referenced-symbols with deep validation test_find_referenced_symbols_enhanced() { local lang="$1" @@ -232,11 +257,11 @@ test_find_referenced_symbols_enhanced() { local data="{\"identifier_position\":{\"path\":\"$file\",\"position\":{\"line\":$line,\"character\":$char}},\"full_scan\":false}" # Make request - local curl_cmd="curl -s -w '\n%{http_code}' --max-time 30 -X POST -H 'Content-Type: application/json' -d '{\"jsonrpc\":\"2.0\",\"id\":\"$TOTAL_TESTS\",\"method\":\"lsproxy/symbol/findReferencedSymbols\",\"params\":$data}' '$BASE_URL/lsp'" + local curl_cmd="curl -s -w '\n%{http_code}' --max-time 30 -X POST -H 'Content-Type: application/json' -d '$data' '$BASE_URL/v1/symbol/find-referenced-symbols'" if response=$(eval "$curl_cmd" 2>&1); then # Split response body and status code - local body=$(echo "$response" | sed '$d' | jq .result) + local body=$(echo "$response" | sed '$d') local status=$(echo "$response" | tail -n 1) # Validate HTTP status code @@ -357,7 +382,7 @@ else echo -e "${YELLOW} Waiting for service and language health (up to 100s)...${NC}" ready=false for i in $(seq 1 100); do - HEALTH=$(curl -sf "${BASE_URL}/system/health" || true) + HEALTH=$(curl -sf "${BASE_URL}/v1/system/health" || true) STATUS=$(echo "$HEALTH" | jq -r '.status' 2>/dev/null || echo "") LANG_PENDING=$(echo "$HEALTH" | jq -r '.languages | to_entries[]? | select(.value != true) | .key' 2>/dev/null || true) @@ -396,7 +421,7 @@ echo echo -e "${YELLOW}1. System Health Check${NC}" test_endpoint "Health Check" \ "GET" \ - "/system/health" \ + "/v1/system/health" \ "" \ "200" \ "jq -e '.status == \"ok\"' > /dev/null" @@ -404,8 +429,9 @@ echo # Test 2: List Files (language-agnostic) echo -e "${YELLOW}2. Workspace Endpoints (Language-Agnostic)${NC}" -test_lsp_method "List Files" \ - "lsproxy/workspace/listFiles" \ +test_endpoint "List Files" \ + "GET" \ + "/v1/workspace/list-files" \ "" \ "200" \ "jq -e 'type == \"array\" and length > 0' > /dev/null" @@ -422,65 +448,82 @@ while IFS='|' read -r lang test_file symbol_name symbol_line symbol_char health_ # Skip empty lines [ -z "$lang" ] && continue + test_uri="$WORKSPACE_URI/$test_file" + echo -e "${BLUE}Testing language: $(echo $lang | tr '[:lower:]' '[:upper:]')${NC}" # Health check for this language test_endpoint "Health ($lang)" \ "GET" \ - "/system/health" \ + "/v1/system/health" \ "" \ "200" \ "jq -e '.languages.$health_key == true' > /dev/null" # Read Source Code - test_lsp_method "Read Source ($lang)" \ - "lsproxy/workspace/readSourceCode" \ + test_endpoint "Read Source ($lang)" \ + "POST" \ + "/v1/workspace/read-source-code" \ "{\"path\":\"$test_file\"}" \ "200" \ "jq -e '.source_code | type == \"string\" and length > 0' > /dev/null" # Read Source Code with Range - test_lsp_method "Read Source with Range ($lang)" \ - "lsproxy/workspace/readSourceCode" \ + test_endpoint "Read Source with Range ($lang)" \ + "POST" \ + "/v1/workspace/read-source-code" \ "{\"path\":\"$test_file\",\"range\":{\"start\":{\"line\":0,\"character\":0},\"end\":{\"line\":1,\"character\":0}}}" \ "200" \ "jq -e '.source_code | type == \"string\"' > /dev/null" # Find Definition (assert selected identifier and at least one definition) - test_lsp_method "Find Definition ($lang)" \ - "lsproxy/symbol/findDefinition" \ + test_endpoint "Find Definition ($lang)" \ + "POST" \ + "/v1/symbol/find-definition" \ "{\"position\":{\"path\":\"$test_file\",\"position\":{\"line\":$symbol_line,\"character\":$symbol_char}},\"include_source_code\":false,\"include_raw_response\":false}" \ "200" \ "jq -e '.selected_identifier.name == \"$symbol_name\" and (.definitions | length) >= 0 and (.selected_identifier.file_range.path == \"$test_file\")' > /dev/null" # Find References (assert selected identifier matches and references is an array) - test_lsp_method "Find References ($lang)" \ - "lsproxy/symbol/findReferences" \ + test_endpoint "Find References ($lang)" \ + "POST" \ + "/v1/symbol/find-references" \ "{\"identifier_position\":{\"path\":\"$test_file\",\"position\":{\"line\":$symbol_line,\"character\":$symbol_char}},\"include_code_context_lines\":0}" \ "200" \ "jq -e '.selected_identifier.name == \"$symbol_name\" and .selected_identifier.file_range.path == \"$test_file\" and (.references | type == \"array\")' > /dev/null" # Find Referenced Symbols - test_lsp_method "Find Referenced Symbols ($lang)" \ - "lsproxy/symbol/findReferencedSymbols" \ + test_endpoint "Find Referenced Symbols ($lang)" \ + "POST" \ + "/v1/symbol/find-referenced-symbols" \ "{\"identifier_position\":{\"path\":\"$test_file\",\"position\":{\"line\":$symbol_line,\"character\":$symbol_char}},\"full_scan\":false}" \ "200" \ "jq -e 'type == \"object\"' > /dev/null" # Definitions in File - test_lsp_method "Definitions in File ($lang)" \ - "lsproxy/symbol/definitionsInFile" \ - "{\"file_path\":\"$test_file\"}" \ + test_endpoint "Definitions in File ($lang)" \ + "GET" \ + "/v1/symbol/definitions-in-file?file_path=$test_file" \ + "" \ "200" \ "jq -e 'type == \"array\"' > /dev/null" # Find Identifier - test_lsp_method "Find Identifier ($lang)" \ - "lsproxy/symbol/findIdentifier" \ + test_endpoint "Find Identifier ($lang)" \ + "POST" \ + "/v1/symbol/find-identifier" \ "{\"path\":\"$test_file\",\"name\":\"$symbol_name\"}" \ "200" \ "jq -e 'type == \"object\"' > /dev/null" + # Find Definition (assert selected identifier and at least one definition) + test_endpoint "LSP GoTo Definition ($lang)" \ + "POST" \ + "/lsp" \ + "{\"jsonrpc\":\"2.0\",\"id\":\"$TOTAL_TESTS\",\"method\":\"textDocument/definition\",\"params\":{\"textDocument\":{\"uri\":\"$test_uri\"},\"position\":{\"line\":$symbol_line,\"character\":$symbol_char}}}" \ + "200" \ + "jq -e '.result | if type == \"array\" then . else [.] end | length > 0' > /dev/null" + echo done <<< "$LANGUAGE_CONFIGS" diff --git a/scripts/test-container-lifecycle.sh b/scripts/test-container-lifecycle.sh index fe09350d..6563ccb1 100755 --- a/scripts/test-container-lifecycle.sh +++ b/scripts/test-container-lifecycle.sh @@ -108,7 +108,7 @@ CONTAINERS_STARTED=true echo -e "${YELLOW}Waiting for service to initialize (up to 60s)...${NC}" ready=false for i in $(seq 1 60); do - HEALTH=$(curl -sf http://localhost:4444/v2/system/health || true) + HEALTH=$(curl -sf http://localhost:4444/v1/system/health || true) STATUS=$(echo "$HEALTH" | jq -r '.status' 2>/dev/null || echo "") LANG_PENDING=$(echo "$HEALTH" | jq -r '.languages | to_entries[]? | select(.value != true) | .key' 2>/dev/null || true) @@ -139,7 +139,7 @@ test_step "Service container running" \ # Test 4: Service health check test_step "Service health check responds" \ - "curl -sf http://localhost:4444/v2/system/health > /dev/null" + "curl -sf http://localhost:4444/v1/system/health > /dev/null" # Test 5: Verify language containers were spawned echo @@ -159,17 +159,13 @@ TESTS_RUN=$((TESTS_RUN + 1)) # Test 6: Test an actual API endpoint test_step "List files endpoint works" \ - "curl -sf -X POST http://localhost:4444/v2/lsp \ - -H 'Content-Type: application/json' \ - -d '{\"jsonrpc\":\"2.0\",\"method\":\"lsproxy/workspace/listFiles\",\"id\":1}' \ - | jq -e '.result | type == \"array\"' > /dev/null" + "curl -sf http://localhost:4444/v1/workspace/list-files | jq -e 'type == \"array\"' > /dev/null" # Test 7: Test language-specific endpoint test_step "Python language works" \ - "curl -sf -X POST http://localhost:4444/v2/lsp \ + "curl -sf -X POST http://localhost:4444/v1/workspace/read-source-code \ -H 'Content-Type: application/json' \ - -d '{\"jsonrpc\":\"2.0\",\"method\":\"lsproxy/workspace/readSourceCode\",\"id\":2,\"params\":{\"path\":\"main.py\"}}' \ - | jq -e '.result.source_code | length > 0' > /dev/null" + -d '{\"path\":\"main.py\"}' | jq -e '.source_code | length > 0' > /dev/null" # Test 8: Stop service and verify cleanup echo diff --git a/scripts/test-watchdog.sh b/scripts/test-watchdog.sh index 3488ed08..5c5e0d2a 100755 --- a/scripts/test-watchdog.sh +++ b/scripts/test-watchdog.sh @@ -55,7 +55,7 @@ wait_for_service_ready() { echo -e "${YELLOW}Waiting for service to initialize (up to ${timeout}s)...${NC}" local ready=false for i in $(seq 1 "$timeout"); do - HEALTH=$(curl -sf "http://localhost:${port}/v2/system/health" || true) + HEALTH=$(curl -sf "http://localhost:${port}/v1/system/health" || true) STATUS=$(echo "$HEALTH" | jq -r '.status' 2>/dev/null || echo "") LANG_PENDING=$(echo "$HEALTH" | jq -r '.languages | to_entries[]? | select(.value != true) | .key' 2>/dev/null || true) From 1b717de40a67bcf063c8d41d8e5120b903b6b31b Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Fri, 28 Nov 2025 17:43:39 +0100 Subject: [PATCH 30/92] Use a single JSON-RPC message type everywhere --- crates/common/src/api_types.rs | 116 +++++++++++++--------- crates/proxy/src/container/http_client.rs | 4 +- crates/proxy/src/handlers/lsp.rs | 56 ++++++----- crates/wrapper/src/handlers/error.rs | 3 + crates/wrapper/src/handlers/lsp.rs | 19 ++-- crates/wrapper/src/lsp/client.rs | 6 +- crates/wrapper/src/lsp/json_rpc.rs | 28 +----- crates/wrapper/src/manager.rs | 14 ++- 8 files changed, 133 insertions(+), 113 deletions(-) diff --git a/crates/common/src/api_types.rs b/crates/common/src/api_types.rs index 3b48da2c..64d82ddf 100644 --- a/crates/common/src/api_types.rs +++ b/crates/common/src/api_types.rs @@ -843,56 +843,39 @@ pub struct ReadSourceCodeRequest { pub range: Option, } -/// JSON-RPC request or notification +/// Unified JSON-RPC message +/// +/// Because multiple message types flow in both directions (requests & notifications +/// from the client, responses & notifications fro the server), it is easier to work +/// with a unified message type. #[derive(Debug, Clone, Serialize, Deserialize, ToSchema, IntoParams)] -pub struct JsonRpcRequest { +pub struct JsonRpcMessage { /// The JSON-RPC version (always "2.0") #[schema(example = "2.0")] pub jsonrpc: String, - /// Optional request ID (for requests that expect a response) - #[serde(skip_serializing_if = "Option::is_none")] + /// Request ID (required for requests and responses) + #[serde( + default, + skip_serializing_if = "Option::is_none", + deserialize_with = "deserialize_option_value" + )] pub id: Option, - /// The method name + /// Method name (required for requests and notifications) #[schema(example = "textDocument/hover")] - pub method: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub method: Option, - /// The method parameters (structure varies by method) + /// Parameters (optional for requests and notifications) #[serde( default, skip_serializing_if = "Option::is_none", deserialize_with = "deserialize_option_value" )] pub params: Option, -} -impl JsonRpcRequest { - pub fn new( - id: Option, - method: String, - params: Option

, - ) -> Self { - Self { - jsonrpc: "2.0".to_string(), - id: id.map(|id| serde_json::to_value(id).unwrap()), - method, - params: params.map(|p| serde_json::to_value(p).unwrap()), - } - } -} - -/// JSON-RPC response -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -pub struct JsonRpcResponse { - /// The JSON-RPC version (always "2.0") - #[schema(example = "2.0")] - pub jsonrpc: String, - - /// The request ID (matches the request) - pub id: Value, - - /// The result (present on success) + /// Result (required for responses, unless error is present) #[serde( default, skip_serializing_if = "Option::is_none", @@ -900,29 +883,62 @@ pub struct JsonRpcResponse { )] pub result: Option, - /// The error (present on failure) + /// Error (required for responses, unless result is present) #[serde(skip_serializing_if = "Option::is_none")] pub error: Option, } -impl JsonRpcResponse { - pub fn new_result(id: Option, result: R) -> Self { +impl JsonRpcMessage { + pub fn new_notification( + method: String, + params: Option, + ) -> Self { + Self { + jsonrpc: "2.0".to_string(), + id: None, + method: Some(method), + params: params.map(|params| serde_json::to_value(params).unwrap()), + result: None, + error: None, + } + } + + pub fn new_request( + id: Id, + method: String, + params: Option, + ) -> Self { + Self { + jsonrpc: "2.0".to_string(), + id: Some(serde_json::to_value(id).unwrap()), + method: Some(method), + params: params.map(|params| serde_json::to_value(params).unwrap()), + result: None, + error: None, + } + } + + pub fn new_result_response(id: Option, result: R) -> Self { Self { jsonrpc: "2.0".to_string(), - id: id - .map(|id| serde_json::to_value(id).unwrap()) - .unwrap_or(Value::Null), + id: Some(id.map_or(Value::Null, |id| serde_json::to_value(id).unwrap())), + method: None, + params: None, result: Some(serde_json::to_value(result).unwrap()), error: None, } } - pub fn new_error(id: Option, code: i32, message: impl ToString) -> Self { + pub fn new_error_response( + id: Option, + code: i32, + message: impl ToString, + ) -> Self { Self { jsonrpc: "2.0".to_string(), - id: id - .map(|id| serde_json::to_value(id).unwrap()) - .unwrap_or(Value::Null), + id: Some(id.map_or(Value::Null, |id| serde_json::to_value(id).unwrap())), + method: None, + params: None, result: None, error: Some(JsonRpcError { code, @@ -936,10 +952,10 @@ impl JsonRpcResponse { /// JSON-RPC error #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct JsonRpcError { - /// Error code + /// Code pub code: i32, - /// Error message + /// Message pub message: String, /// Optional additional error data @@ -947,6 +963,14 @@ pub struct JsonRpcError { pub data: Option, } +impl fmt::Display for JsonRpcError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "Error {}: {}", self.code, self.message) + } +} + +impl std::error::Error for JsonRpcError {} + /// Custom deserialize function to ensure `Some(Null)` is not reduced to `None`. /// Fields need to be annotated as follows: /// ```skip diff --git a/crates/proxy/src/container/http_client.rs b/crates/proxy/src/container/http_client.rs index 8c9d4d59..0730cf04 100644 --- a/crates/proxy/src/container/http_client.rs +++ b/crates/proxy/src/container/http_client.rs @@ -102,8 +102,8 @@ impl ContainerHttpClient { /// Forward a raw LSP JSON-RPC request to the container pub async fn lsp( &self, - request: &JsonRpcRequest, - ) -> Result> { + request: &JsonRpcMessage, + ) -> Result> { let url = format!("{}/lsp", self.base_url); let response = self.client.post(&url).json(request).send().await?; diff --git a/crates/proxy/src/handlers/lsp.rs b/crates/proxy/src/handlers/lsp.rs index aaeb8200..38b59387 100644 --- a/crates/proxy/src/handlers/lsp.rs +++ b/crates/proxy/src/handlers/lsp.rs @@ -3,7 +3,7 @@ use crate::handlers::container_proxy; use crate::AppState; use actix_web::web::{Data, Json}; use actix_web::HttpResponse; -use common::api_types::{JsonRpcRequest, JsonRpcResponse}; +use common::api_types::JsonRpcMessage; use log::{debug, error, info, warn}; use lsp_types::{ DeclarationCapability, FoldingRangeProviderCapability, HoverProviderCapability, @@ -19,20 +19,20 @@ use url::Url; post, path = "/lsp", tag = "lsp", - request_body = JsonRpcRequest, + request_body = JsonRpcMessage, responses( - (status = 200, description = "LSP request processed successfully", body = JsonRpcResponse), + (status = 200, description = "LSP request processed successfully", body = JsonRpcMessage), (status = 400, description = "Bad request - invalid JSON-RPC format"), (status = 500, description = "Internal server error") ) )] -pub async fn lsp(data: Data, request: Json) -> HttpResponse { +pub async fn lsp(data: Data, request: Json) -> HttpResponse { let lsp_req = request.into_inner(); let method = &lsp_req.method; let req_id = lsp_req.id.clone(); - info!("Received LSP request: id={:?} method={}", &req_id, method); - debug!("LSP request: id={:?}", &lsp_req); + info!("Received LSP request: id={:?} method={:?}", &req_id, method); + debug!("LSP request: {:?}", &lsp_req); // Handle lifecycle requests locally if let Some(response) = handle_lifecycle_request(&lsp_req) { @@ -48,10 +48,10 @@ pub async fn lsp(data: Data, request: Json) -> HttpRes Some(uri) => uri, None => { warn!( - "Could not extract document URI from request for method: {}", + "Could not extract document URI from request for method: {:?}", method ); - return HttpResponse::BadRequest().json(JsonRpcResponse::new_error( + return HttpResponse::BadRequest().json(JsonRpcMessage::new_error_response( req_id, -32602, "Invalid params: could not extract document URI", @@ -65,7 +65,7 @@ pub async fn lsp(data: Data, request: Json) -> HttpRes Ok(path) => path.to_string_lossy().to_string(), Err(_) => { error!("Invalid file URI path: {}", document_uri); - return HttpResponse::BadRequest().json(JsonRpcResponse::new_error( + return HttpResponse::BadRequest().json(JsonRpcMessage::new_error_response( req_id, -32602, "Invalid params: invalid file URI path", @@ -74,7 +74,7 @@ pub async fn lsp(data: Data, request: Json) -> HttpRes }, Ok(_) => { error!("Non-file URI: {}", document_uri); - return HttpResponse::BadRequest().json(JsonRpcResponse::new_error( + return HttpResponse::BadRequest().json(JsonRpcMessage::new_error_response( req_id, -32602, "Invalid params: expected file URI", @@ -82,7 +82,7 @@ pub async fn lsp(data: Data, request: Json) -> HttpRes } Err(e) => { error!("Invalid document URI: {} - {}", document_uri, e); - return HttpResponse::BadRequest().json(JsonRpcResponse::new_error( + return HttpResponse::BadRequest().json(JsonRpcMessage::new_error_response( req_id, -32602, "Invalid params: invalid document URI", @@ -95,7 +95,7 @@ pub async fn lsp(data: Data, request: Json) -> HttpRes Ok(client) => client, Err(e) => { error!("Failed to get container client: {}", e); - return HttpResponse::InternalServerError().json(JsonRpcResponse::new_error( + return HttpResponse::InternalServerError().json(JsonRpcMessage::new_error_response( req_id, -32603, &format!("Internal error: {}", e), @@ -108,7 +108,7 @@ pub async fn lsp(data: Data, request: Json) -> HttpRes if let Some(ref mut params) = converted_request.params { if let Err(e) = convert_json_paths_host_to_container(&data.orchestrator, params).await { error!("Failed to convert request paths: {}", e); - return HttpResponse::InternalServerError().json(JsonRpcResponse::new_error( + return HttpResponse::InternalServerError().json(JsonRpcMessage::new_error_response( req_id, -32603, &format!("Path conversion error: {}", e), @@ -119,7 +119,7 @@ pub async fn lsp(data: Data, request: Json) -> HttpRes // Forward request to container match client.lsp(&converted_request).await { Ok(mut response) => { - info!("Received container response: id={}", response.id); + info!("Received container response: id={:?}", response.id); debug!("Container response: {:?}", response); // Convert response paths from container to host @@ -128,11 +128,13 @@ pub async fn lsp(data: Data, request: Json) -> HttpRes convert_json_paths_container_to_host(&data.orchestrator, result).await { error!("Failed to convert response paths: {}", e); - return HttpResponse::InternalServerError().json(JsonRpcResponse::new_error( - req_id, - -32603, - &format!("Path conversion error: {}", e), - )); + return HttpResponse::InternalServerError().json( + JsonRpcMessage::new_error_response( + req_id, + -32603, + &format!("Path conversion error: {}", e), + ), + ); } } @@ -140,7 +142,7 @@ pub async fn lsp(data: Data, request: Json) -> HttpRes } Err(e) => { error!("Container request failed: {}", e); - HttpResponse::InternalServerError().json(JsonRpcResponse::new_error( + HttpResponse::InternalServerError().json(JsonRpcMessage::new_error_response( req_id, -32603, &format!("Internal error: {}", e), @@ -150,9 +152,12 @@ pub async fn lsp(data: Data, request: Json) -> HttpRes } /// Handle lifecycle requests locally -fn handle_lifecycle_request(request: &JsonRpcRequest) -> Option { +fn handle_lifecycle_request(request: &JsonRpcMessage) -> Option { let req_id = request.id.clone(); - match request.method.as_str() { + let Some(method) = request.method.as_ref() else { + return None; + }; + match method.as_str() { "initialize" => { let mut capabilities = ServerCapabilities::default(); capabilities.call_hierarchy_provider = Some(true.into()); @@ -176,7 +181,7 @@ fn handle_lifecycle_request(request: &JsonRpcRequest) -> Option { .into(), ); capabilities.type_definition_provider = Some(true.into()); - let response = JsonRpcResponse::new_result( + let response = JsonRpcMessage::new_result_response( req_id, InitializeResult { capabilities, @@ -190,12 +195,13 @@ fn handle_lifecycle_request(request: &JsonRpcRequest) -> Option { } "initialized" | "exit" => Some(HttpResponse::Ok().finish()), "shutdown" => { - let response = JsonRpcResponse::new_result(req_id, Value::Null); + let response = JsonRpcMessage::new_result_response(req_id, Value::Null); Some(HttpResponse::Ok().json(response)) } dollar_method if dollar_method.starts_with("$/") => { if req_id.is_some() { - let response = JsonRpcResponse::new_error(req_id, -32601, "Method not found"); + let response = + JsonRpcMessage::new_error_response(req_id, -32601, "Method not found"); Some(HttpResponse::Ok().json(response)) } else { Some(HttpResponse::Ok().finish()) diff --git a/crates/wrapper/src/handlers/error.rs b/crates/wrapper/src/handlers/error.rs index 5912f094..80208e32 100644 --- a/crates/wrapper/src/handlers/error.rs +++ b/crates/wrapper/src/handlers/error.rs @@ -13,6 +13,9 @@ impl IntoHttpResponse for LspManagerError { Self::FileNotFound(path) => HttpResponse::BadRequest().json(ErrorResponse { error: format!("File not found: {}", path), }), + Self::BadRequest(path) => HttpResponse::BadRequest().json(ErrorResponse { + error: format!("{}", path), + }), Self::LspClientNotFound(lang) => { HttpResponse::InternalServerError().json(ErrorResponse { error: format!("LSP client not found for {:?}", lang), diff --git a/crates/wrapper/src/handlers/lsp.rs b/crates/wrapper/src/handlers/lsp.rs index 39073cd7..fc399c9a 100644 --- a/crates/wrapper/src/handlers/lsp.rs +++ b/crates/wrapper/src/handlers/lsp.rs @@ -1,6 +1,6 @@ use crate::AppState; use actix_web::{web, HttpResponse}; -use common::api_types::{JsonRpcRequest, JsonRpcResponse}; +use common::api_types::JsonRpcMessage; use log::{debug, error, info}; /// Forward raw LSP JSON-RPC requests to the LSP server @@ -10,22 +10,22 @@ use log::{debug, error, info}; #[utoipa::path( post, path = "/lsp", - request_body = JsonRpcRequest, + request_body = JsonRpcMessage, responses( - (status = 200, description = "Identifier retrieved successfully", body = JsonRpcResponse), + (status = 200, description = "Identifier retrieved successfully", body = JsonRpcMessage), (status = 400, description = "Bad request"), (status = 500, description = "Internal server error") ) )] pub async fn lsp( app_state: web::Data, - request: web::Json, + request: web::Json, ) -> HttpResponse { let lsp_req = request.into_inner(); let req_id = lsp_req.id.clone(); info!( - "Received LSP request: id={:?} method={}", + "Received LSP request: id={:?} method={:?}", &req_id, &lsp_req.method ); debug!("LSP request: {:?}", &lsp_req); @@ -33,14 +33,17 @@ pub async fn lsp( // Forward the request to the LSP server match app_state.manager.lsp(lsp_req).await { Ok(response) => { - info!("Received process response: id={}", response.id); + info!("Received process response: id={:?}", response.id); debug!("Process response: {:?}", response); HttpResponse::Ok().json(response) } Err(e) => { error!("LSP request failed: {}", e); - let error = - JsonRpcResponse::new_error(req_id, -32603, format!("Internal error: {}", e)); + let error = JsonRpcMessage::new_error_response( + req_id, + -32603, + format!("Internal error: {}", e), + ); HttpResponse::InternalServerError().json(error) } } diff --git a/crates/wrapper/src/lsp/client.rs b/crates/wrapper/src/lsp/client.rs index 2ca9d2f4..11207212 100644 --- a/crates/wrapper/src/lsp/client.rs +++ b/crates/wrapper/src/lsp/client.rs @@ -137,7 +137,11 @@ pub trait LspClient: Send { loop { if let Ok(raw_response) = process.receive().await { if let Ok(message) = json_rpc.parse_message(&raw_response) { - if let Some(id) = message.id { + if let Some(id) = &message.id { + // we always use u64 ids here, so the server process should respond with those as well + let id = id.as_u64().unwrap_or_else(|| { + panic!("process responded with invalid id type") + }); debug!("Received response for request {}", id); if let Ok(Some(sender)) = pending_requests.remove_request(id).await { if sender.send(message.clone()).is_err() { diff --git a/crates/wrapper/src/lsp/json_rpc.rs b/crates/wrapper/src/lsp/json_rpc.rs index d1b7b19b..cc92de47 100644 --- a/crates/wrapper/src/lsp/json_rpc.rs +++ b/crates/wrapper/src/lsp/json_rpc.rs @@ -1,8 +1,7 @@ -use serde::{Deserialize, Serialize}; +use common::api_types::{JsonRpcError, JsonRpcMessage}; use serde_json::Value; use std::collections::HashMap; use std::error::Error; -use std::fmt; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; use tokio::sync::broadcast::{channel, Receiver, Sender}; @@ -15,31 +14,6 @@ pub trait JsonRpc: Send + Sync { fn parse_message(&self, data: &str) -> Result; } -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct JsonRpcMessage { - pub jsonrpc: String, - pub id: Option, - pub method: Option, - pub params: Option, - pub result: Option, - pub error: Option, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct JsonRpcError { - pub code: i32, - pub message: String, - pub data: Option, -} - -impl fmt::Display for JsonRpcError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "Error {}: {}", self.code, self.message) - } -} - -impl std::error::Error for JsonRpcError {} - #[derive(Clone)] pub struct JsonRpcHandler { id_counter: Arc, diff --git a/crates/wrapper/src/manager.rs b/crates/wrapper/src/manager.rs index 8d9264d0..5d05a05a 100644 --- a/crates/wrapper/src/manager.rs +++ b/crates/wrapper/src/manager.rs @@ -3,7 +3,7 @@ use crate::lsp::client::LspClient; /// /// Unlike the main Nuanced LSP Manager that orchestrates multiple language servers, /// this Manager wraps a single LSP client for the configured language. -use common::api_types::{get_mount_dir, Identifier, JsonRpcRequest, JsonRpcResponse, Symbol}; +use common::api_types::{get_mount_dir, Identifier, JsonRpcMessage, Symbol}; use common::ast_grep::client::AstGrepClient; use common::ast_grep::types::AstGrepMatch; use common::utils::file_utils::uri_to_relative_path_string; @@ -19,6 +19,9 @@ pub enum LspManagerError { #[error("File not found: {0}")] FileNotFound(String), + #[error("Bad request: {0}")] + BadRequest(String), + #[error("Internal error: {0}")] InternalError(String), @@ -285,13 +288,16 @@ impl Manager { /// Forward a raw LSP JSON-RPC request to the LSP server /// /// This provides lightweight pass-through of JSON-RPC requests with minimal processing. - pub async fn lsp(&self, request: JsonRpcRequest) -> Result { + pub async fn lsp(&self, request: JsonRpcMessage) -> Result { let req_id = request.id; + let Some(method) = request.method else { + return Err(LspManagerError::BadRequest("missing method".to_string())); + }; // Forward to LSP server let mut locked_client = self.client.lock().await; let result = locked_client - .send_request(&request.method, request.params) + .send_request(&method, request.params) .await .map_err(|e| { error!("Failed to forward LSP request: {}", e); @@ -299,7 +305,7 @@ impl Manager { })?; // Build response - let response = JsonRpcResponse::new_result(req_id, result); + let response = JsonRpcMessage::new_result_response(req_id, result); Ok(response) } From 8c41cba5f8dee10a0508e2da329934aa2dbfc66c Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Fri, 28 Nov 2025 17:45:11 +0100 Subject: [PATCH 31/92] Remove out-of-date comment --- clients/typescript/script/generate-version.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/clients/typescript/script/generate-version.mjs b/clients/typescript/script/generate-version.mjs index 3ea0e2a3..ca2490b3 100755 --- a/clients/typescript/script/generate-version.mjs +++ b/clients/typescript/script/generate-version.mjs @@ -96,7 +96,7 @@ const DEFAULT_WATCHDOG_IMAGE = `${WATCHDOG_IMAGE_BASE}:${nuancedLSPWatchdogVersi // Ensure output dir exists fs.mkdirSync(outDir, { recursive: true }); -// Emit TypeScript module (formatted to pass Prettier) +// Emit TypeScript module const contents = `/* eslint-ignore */ /* tslint-ignore */ // AUTO-GENERATED FILE. Do not edit. From 6c87d808858f2d529a42732edb4c39aa300fcf65 Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Fri, 28 Nov 2025 17:52:00 +0100 Subject: [PATCH 32/92] Use constants instead of literals for error codes --- clients/typescript/src/server.ts | 6 +++--- crates/common/src/api_types.rs | 9 +++++++++ crates/proxy/src/handlers/lsp.rs | 25 ++++++++++++++----------- crates/wrapper/src/handlers/lsp.rs | 4 ++-- crates/wrapper/src/lsp/json_rpc.rs | 4 ++-- 5 files changed, 30 insertions(+), 18 deletions(-) diff --git a/clients/typescript/src/server.ts b/clients/typescript/src/server.ts index 17ae16b8..ba509269 100644 --- a/clients/typescript/src/server.ts +++ b/clients/typescript/src/server.ts @@ -6,7 +6,7 @@ import type { JsonRpcResponse, ServerCommandOptions, } from "./types.js"; -import { isErr } from "./types.js"; +import { isErr, JsonRpcErrorCode } from "./types.js"; // ---- Types ------------------------------------------------------------------ @@ -208,7 +208,7 @@ class LspServer { }; } else { error = { - code: result.data.status_code ?? -32603, + code: result.data.status_code ?? JsonRpcErrorCode.InternalError, message: "LSP forwarding error", data: result.data.error, }; @@ -229,7 +229,7 @@ class LspServer { jsonrpc: "2.0", id: message.id, error: { - code: -32603, + code: JsonRpcErrorCode.InternalError, message: "Internal error", data: String(err), }, diff --git a/crates/common/src/api_types.rs b/crates/common/src/api_types.rs index 64d82ddf..e4e334fd 100644 --- a/crates/common/src/api_types.rs +++ b/crates/common/src/api_types.rs @@ -971,6 +971,15 @@ impl fmt::Display for JsonRpcError { impl std::error::Error for JsonRpcError {} +#[allow(non_snake_case, non_upper_case_globals)] +pub mod JsonRpcErrorCode { + pub const ParseError: i32 = -32700; + pub const InvalidRequest: i32 = -32600; + pub const MethodNotFound: i32 = -32601; + pub const InvalidParams: i32 = -32602; + pub const InternalError: i32 = -32603; +} + /// Custom deserialize function to ensure `Some(Null)` is not reduced to `None`. /// Fields need to be annotated as follows: /// ```skip diff --git a/crates/proxy/src/handlers/lsp.rs b/crates/proxy/src/handlers/lsp.rs index 38b59387..d9408980 100644 --- a/crates/proxy/src/handlers/lsp.rs +++ b/crates/proxy/src/handlers/lsp.rs @@ -3,7 +3,7 @@ use crate::handlers::container_proxy; use crate::AppState; use actix_web::web::{Data, Json}; use actix_web::HttpResponse; -use common::api_types::JsonRpcMessage; +use common::api_types::{JsonRpcErrorCode, JsonRpcMessage}; use log::{debug, error, info, warn}; use lsp_types::{ DeclarationCapability, FoldingRangeProviderCapability, HoverProviderCapability, @@ -53,7 +53,7 @@ pub async fn lsp(data: Data, request: Json) -> HttpRes ); return HttpResponse::BadRequest().json(JsonRpcMessage::new_error_response( req_id, - -32602, + JsonRpcErrorCode::InvalidParams, "Invalid params: could not extract document URI", )); } @@ -67,7 +67,7 @@ pub async fn lsp(data: Data, request: Json) -> HttpRes error!("Invalid file URI path: {}", document_uri); return HttpResponse::BadRequest().json(JsonRpcMessage::new_error_response( req_id, - -32602, + JsonRpcErrorCode::InvalidParams, "Invalid params: invalid file URI path", )); } @@ -76,7 +76,7 @@ pub async fn lsp(data: Data, request: Json) -> HttpRes error!("Non-file URI: {}", document_uri); return HttpResponse::BadRequest().json(JsonRpcMessage::new_error_response( req_id, - -32602, + JsonRpcErrorCode::InvalidParams, "Invalid params: expected file URI", )); } @@ -84,7 +84,7 @@ pub async fn lsp(data: Data, request: Json) -> HttpRes error!("Invalid document URI: {} - {}", document_uri, e); return HttpResponse::BadRequest().json(JsonRpcMessage::new_error_response( req_id, - -32602, + JsonRpcErrorCode::InvalidParams, "Invalid params: invalid document URI", )); } @@ -97,7 +97,7 @@ pub async fn lsp(data: Data, request: Json) -> HttpRes error!("Failed to get container client: {}", e); return HttpResponse::InternalServerError().json(JsonRpcMessage::new_error_response( req_id, - -32603, + JsonRpcErrorCode::InternalError, &format!("Internal error: {}", e), )); } @@ -110,7 +110,7 @@ pub async fn lsp(data: Data, request: Json) -> HttpRes error!("Failed to convert request paths: {}", e); return HttpResponse::InternalServerError().json(JsonRpcMessage::new_error_response( req_id, - -32603, + JsonRpcErrorCode::InternalError, &format!("Path conversion error: {}", e), )); } @@ -131,7 +131,7 @@ pub async fn lsp(data: Data, request: Json) -> HttpRes return HttpResponse::InternalServerError().json( JsonRpcMessage::new_error_response( req_id, - -32603, + JsonRpcErrorCode::InternalError, &format!("Path conversion error: {}", e), ), ); @@ -144,7 +144,7 @@ pub async fn lsp(data: Data, request: Json) -> HttpRes error!("Container request failed: {}", e); HttpResponse::InternalServerError().json(JsonRpcMessage::new_error_response( req_id, - -32603, + JsonRpcErrorCode::InternalError, &format!("Internal error: {}", e), )) } @@ -200,8 +200,11 @@ fn handle_lifecycle_request(request: &JsonRpcMessage) -> Option { } dollar_method if dollar_method.starts_with("$/") => { if req_id.is_some() { - let response = - JsonRpcMessage::new_error_response(req_id, -32601, "Method not found"); + let response = JsonRpcMessage::new_error_response( + req_id, + JsonRpcErrorCode::MethodNotFound, + "Method not found", + ); Some(HttpResponse::Ok().json(response)) } else { Some(HttpResponse::Ok().finish()) diff --git a/crates/wrapper/src/handlers/lsp.rs b/crates/wrapper/src/handlers/lsp.rs index fc399c9a..0a4c5475 100644 --- a/crates/wrapper/src/handlers/lsp.rs +++ b/crates/wrapper/src/handlers/lsp.rs @@ -1,6 +1,6 @@ use crate::AppState; use actix_web::{web, HttpResponse}; -use common::api_types::JsonRpcMessage; +use common::api_types::{JsonRpcErrorCode, JsonRpcMessage}; use log::{debug, error, info}; /// Forward raw LSP JSON-RPC requests to the LSP server @@ -41,7 +41,7 @@ pub async fn lsp( error!("LSP request failed: {}", e); let error = JsonRpcMessage::new_error_response( req_id, - -32603, + JsonRpcErrorCode::InternalError, format!("Internal error: {}", e), ); HttpResponse::InternalServerError().json(error) diff --git a/crates/wrapper/src/lsp/json_rpc.rs b/crates/wrapper/src/lsp/json_rpc.rs index cc92de47..d477d595 100644 --- a/crates/wrapper/src/lsp/json_rpc.rs +++ b/crates/wrapper/src/lsp/json_rpc.rs @@ -1,4 +1,4 @@ -use common::api_types::{JsonRpcError, JsonRpcMessage}; +use common::api_types::{JsonRpcError, JsonRpcErrorCode, JsonRpcMessage}; use serde_json::Value; use std::collections::HashMap; use std::error::Error; @@ -60,7 +60,7 @@ impl JsonRpc for JsonRpcHandler { fn parse_message(&self, data: &str) -> Result { serde_json::from_str(data).map_err(|e| JsonRpcError { - code: -32700, + code: JsonRpcErrorCode::ParseError, message: e.to_string(), data: None, }) From 92bd33c970d09945b4107e884ca2075497d5c462 Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Fri, 28 Nov 2025 17:54:42 +0100 Subject: [PATCH 33/92] Prevent weird formatting --- crates/common/src/api_types.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/common/src/api_types.rs b/crates/common/src/api_types.rs index e4e334fd..4f62b77f 100644 --- a/crates/common/src/api_types.rs +++ b/crates/common/src/api_types.rs @@ -257,9 +257,11 @@ impl SupportedLanguages { /// - Core versions from original support (3.2.2, 3.2.6, 3.3.5) /// - Last 1 year of releases (Nov 2024 - Nov 2025): 3.3.6-3.3.10, 3.4.0-3.4.7 /// These have dedicated container images with exact version matching. + #[rustfmt::skip] const SUPPORTED_RUBY_VERSIONS: &'static [&'static str] = &[ // Core 3.2.x versions - "3.2.2", "3.2.6", // 3.3.x series + "3.2.2", "3.2.6", + // 3.3.x series "3.3.5", "3.3.6", "3.3.7", "3.3.8", "3.3.9", "3.3.10", // 3.4.x series (Dec 2024 - Oct 2025) "3.4.0", "3.4.1", "3.4.2", "3.4.3", "3.4.4", "3.4.5", "3.4.6", "3.4.7", From e3236c527511093a24de8084f1c3a3369307d6b4 Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Fri, 28 Nov 2025 18:07:22 +0100 Subject: [PATCH 34/92] small cleanups --- crates/proxy/src/lib.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/crates/proxy/src/lib.rs b/crates/proxy/src/lib.rs index 9cdf3125..fc0fb80f 100644 --- a/crates/proxy/src/lib.rs +++ b/crates/proxy/src/lib.rs @@ -86,14 +86,13 @@ pub fn check_mount_dir() -> std::io::Result<()> { (name = "nuanced-lsp-api", description = "Nuanced LSP API") ), servers( - (url = "http://localhost:4444/v1", description = "Nuanced LSP API v1"), + (url = "http://localhost:4444/v1", description = "API server v1"), ) )] pub struct ApiDoc; pub struct AppState { orchestrator: Arc, - #[allow(dead_code)] workspace_path: String, initialization_complete: Arc, } @@ -286,9 +285,13 @@ pub async fn run_server_with_port_and_host( .app_data(app_state.clone()) .configure(|cfg| { if let Some(ref middleware) = jwt_middleware { - cfg.service(api_scope.wrap(middleware.clone())).route("/lsp", post().to(lsp).wrap(middleware.clone())); + cfg + .service(api_scope.wrap(middleware.clone())) + .route("/lsp", post().to(lsp).wrap(middleware.clone())); } else { - cfg.service(api_scope).route("/lsp", post().to(lsp)); + cfg + .service(api_scope) + .route("/lsp", post().to(lsp)); } }) .service( From ead9dbce9963086b1941aaf47a967697be087b71 Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Fri, 28 Nov 2025 18:14:56 +0100 Subject: [PATCH 35/92] Do not rely on local images in the end --- crates/proxy/tests/container_orchestration_test.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/proxy/tests/container_orchestration_test.rs b/crates/proxy/tests/container_orchestration_test.rs index 34722be7..f1f69313 100644 --- a/crates/proxy/tests/container_orchestration_test.rs +++ b/crates/proxy/tests/container_orchestration_test.rs @@ -22,7 +22,7 @@ use tokio::sync::Mutex; use tokio::time::sleep; use common::api_types::SupportedLanguages; -use proxy::container::{language_image, PROXY_IMAGE_BASE, WRAPPER_IMAGE_BASE}; +use proxy::container::{language_image_ghcr, PROXY_IMAGE_BASE, WRAPPER_IMAGE_BASE}; const SERVICE_PORT: u16 = 14444; // Use non-standard port to avoid conflicts const CONTAINER_PORT: u16 = 4444; // Port the service listens on inside container @@ -36,7 +36,7 @@ fn test_proxy_image() -> String { format!("{PROXY_IMAGE_BASE}:{TEST_RUST_IMAGE_VERSION}") } fn test_python_image() -> String { - language_image(&SupportedLanguages::Python) + language_image_ghcr(&SupportedLanguages::Python) } /// Shared test fixture that lives for the entire test suite From 529352d2803556ff8961252d392eeb7fb92f94ff Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Fri, 28 Nov 2025 18:53:28 +0100 Subject: [PATCH 36/92] Support setting test languages with flag --- clients/typescript/script/test | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/clients/typescript/script/test b/clients/typescript/script/test index 16aa65c3..c3c3b4c2 100755 --- a/clients/typescript/script/test +++ b/clients/typescript/script/test @@ -37,6 +37,7 @@ WRAPPER_IMAGE= # allows overriding the default nuanced-lsp-wrapper SYMBOL_SCENARIO_DELAY= # delay (in seconds) before running symbol scenario tests. WORKSPACE_SCENARIO_DELAY= # delay (in seconds) before running workspace scenario NUANCED_LSP_TIMEOUT= # number of seconds each test case is allowed to run. +NUANCED_LANGUAGES= # languages to run tests for VITEST_PASSTHRU=() # additional args to pass to vitest (e.g. specific test files or --testNamePattern). while [[ $# -gt 0 ]]; do @@ -112,6 +113,15 @@ while [[ $# -gt 0 ]]; do exit 2 fi ;; + --languages) + if [[ $# -gt 1 && "$2" != -* ]]; then + export NUANCED_LANGUAGES="$2" + shift 2 + else + echo "Error: --languages requires a comma-seprated list of languages" >&2 + exit 2 + fi + ;; *) VITEST_PASSTHRU+=("$1") shift From 62e690867bcbb56028d419b0a86003feb51866ad Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Mon, 1 Dec 2025 14:19:39 +0100 Subject: [PATCH 37/92] Rename Manager to ApiManager --- .../src/handlers/definitions_in_file.rs | 6 +- crates/wrapper/src/handlers/error.rs | 4 +- .../wrapper/src/handlers/find_definition.rs | 16 +++-- .../wrapper/src/handlers/find_identifier.rs | 2 +- .../src/handlers/find_referenced_symbols.rs | 8 +-- .../wrapper/src/handlers/find_references.rs | 24 ++++---- crates/wrapper/src/handlers/lsp.rs | 2 +- crates/wrapper/src/lib.rs | 6 +- crates/wrapper/src/lsp/mod.rs | 1 + crates/wrapper/src/main.rs | 11 ++-- .../src/{manager.rs => managers/api.rs} | 61 ++++++++++--------- crates/wrapper/src/managers/mod.rs | 1 + 12 files changed, 77 insertions(+), 65 deletions(-) rename crates/wrapper/src/{manager.rs => managers/api.rs} (84%) create mode 100644 crates/wrapper/src/managers/mod.rs diff --git a/crates/wrapper/src/handlers/definitions_in_file.rs b/crates/wrapper/src/handlers/definitions_in_file.rs index 98b3da84..c1c65bde 100644 --- a/crates/wrapper/src/handlers/definitions_in_file.rs +++ b/crates/wrapper/src/handlers/definitions_in_file.rs @@ -41,7 +41,11 @@ pub async fn definitions_in_file( info.file_path ); - match data.manager.get_definitions_in_file(&info.file_path).await { + match data + .api_manager + .get_definitions_in_file(&info.file_path) + .await + { Ok(symbols) => { let symbol_response: Vec = symbols .into_iter() diff --git a/crates/wrapper/src/handlers/error.rs b/crates/wrapper/src/handlers/error.rs index 80208e32..0d17f9b5 100644 --- a/crates/wrapper/src/handlers/error.rs +++ b/crates/wrapper/src/handlers/error.rs @@ -1,4 +1,4 @@ -use crate::manager::LspManagerError; +use crate::managers::api::ApiManagerError; use actix_web::HttpResponse; use common::api_types::ErrorResponse; @@ -6,7 +6,7 @@ pub trait IntoHttpResponse { fn into_http_response(self) -> HttpResponse; } -impl IntoHttpResponse for LspManagerError { +impl IntoHttpResponse for ApiManagerError { fn into_http_response(self) -> HttpResponse { log::error!("LSP error: {}", self); match self { diff --git a/crates/wrapper/src/handlers/find_definition.rs b/crates/wrapper/src/handlers/find_definition.rs index 3d504ce0..8ae76309 100644 --- a/crates/wrapper/src/handlers/find_definition.rs +++ b/crates/wrapper/src/handlers/find_definition.rs @@ -1,5 +1,5 @@ use crate::handlers::error::IntoHttpResponse; -use crate::manager::{LspManagerError, Manager}; +use crate::managers::api::{ApiManager, ApiManagerError}; use actix_web::web::{Data, Json}; use actix_web::HttpResponse; use common::api_types::{CodeContext, FileRange, Position, Range}; @@ -56,7 +56,11 @@ pub async fn find_definition( position: info.position.position.clone(), }; - let file_identifiers = match data.manager.get_file_identifiers(&file_position.path).await { + let file_identifiers = match data + .api_manager + .get_file_identifiers(&file_position.path) + .await + { Ok(identifiers) => identifiers, Err(e) => { error!("Failed to get file identifiers: {:?}", e); @@ -77,7 +81,7 @@ pub async fn find_definition( // Call LSP directly (no ast-grep for identifier detection) let definitions = match data - .manager + .api_manager .find_definition( &info.position.path, LspPosition { @@ -94,7 +98,7 @@ pub async fn find_definition( }; let source_code_context = if info.include_source_code { - match fetch_definition_source_code(&data.manager, &definitions).await { + match fetch_definition_source_code(&data.api_manager, &definitions).await { Ok(context) => Some(context), Err(e) => { error!("Failed to fetch definition source code: {:?}", e); @@ -134,9 +138,9 @@ pub async fn find_definition( } async fn fetch_definition_source_code( - manager: &Manager, + manager: &ApiManager, definitions_response: &GotoDefinitionResponse, -) -> Result, LspManagerError> { +) -> Result, ApiManagerError> { let mut code_contexts = Vec::new(); let definitions: &Vec = match definitions_response { GotoDefinitionResponse::Scalar(definition) => &vec![definition.clone()], diff --git a/crates/wrapper/src/handlers/find_identifier.rs b/crates/wrapper/src/handlers/find_identifier.rs index c05bddb1..f2a72834 100644 --- a/crates/wrapper/src/handlers/find_identifier.rs +++ b/crates/wrapper/src/handlers/find_identifier.rs @@ -47,7 +47,7 @@ pub async fn find_identifier( "Received identifier request for file: {}, name: {}, position: {:?}", info.path, info.name, info.position ); - let file_identifiers = match data.manager.get_file_identifiers(&info.path).await { + let file_identifiers = match data.api_manager.get_file_identifiers(&info.path).await { Ok(identifiers) => identifiers, Err(e) => { error!("Failed to get file identifiers: {:?}", e); diff --git a/crates/wrapper/src/handlers/find_referenced_symbols.rs b/crates/wrapper/src/handlers/find_referenced_symbols.rs index 40a04eec..831601c2 100644 --- a/crates/wrapper/src/handlers/find_referenced_symbols.rs +++ b/crates/wrapper/src/handlers/find_referenced_symbols.rs @@ -53,7 +53,7 @@ pub async fn find_referenced_symbols( ); let referenecd_ast_symbols = match data - .manager + .api_manager .find_referenced_symbols( &info.identifier_position.path, LspPosition { @@ -136,7 +136,7 @@ pub async fn find_referenced_symbols( }; match data - .manager + .api_manager .get_symbol_from_position(&def.path, &def_position) .await { @@ -163,7 +163,7 @@ pub async fn find_referenced_symbols( // Solution: Use ast-grep to get all identifiers in the file, find the one // matching by name and line number, then call get_symbol_from_position // using that identifier's position (which aligns with documentSymbol). - match data.manager.get_file_identifiers(&def.path).await { + match data.api_manager.get_file_identifiers(&def.path).await { Ok(identifiers) => { // Find the identifier on the same line as the definition with matching name if let Some(found_identifier) = identifiers.iter().find(|id| { @@ -181,7 +181,7 @@ pub async fn find_referenced_symbols( }; if let Ok(symbol) = data - .manager + .api_manager .get_symbol_from_position(&def.path, &id_position) .await { diff --git a/crates/wrapper/src/handlers/find_references.rs b/crates/wrapper/src/handlers/find_references.rs index a2e280e1..4a4d4071 100644 --- a/crates/wrapper/src/handlers/find_references.rs +++ b/crates/wrapper/src/handlers/find_references.rs @@ -5,7 +5,7 @@ use lsp_types::{Location, Position as LspPosition}; use crate::handlers::error::IntoHttpResponse; use crate::handlers::utils; -use crate::manager::{LspManagerError, Manager}; +use crate::managers::api::{ApiManager, ApiManagerError}; use crate::AppState; use common::api_types::{ get_mount_dir, CodeContext, ErrorResponse, FilePosition, FileRange, FindReferencesRequest, @@ -55,7 +55,7 @@ pub async fn find_references( ); let file_identifiers = match data - .manager + .api_manager .get_file_identifiers(&info.identifier_position.path) .await { @@ -81,9 +81,9 @@ pub async fn find_references( }; let references_result = - find_and_filter_references(&data.manager, &info.identifier_position).await; + find_and_filter_references(&data.api_manager, &info.identifier_position).await; let code_contexts_result = get_code_contexts( - &data.manager, + &data.api_manager, &references_result, info.include_code_context_lines, ) @@ -131,9 +131,9 @@ pub async fn find_references( } async fn find_and_filter_references( - manager: &Manager, + manager: &ApiManager, position: &FilePosition, -) -> Result, LspManagerError> { +) -> Result, ApiManagerError> { let references = manager .find_references( &position.path, @@ -166,10 +166,10 @@ async fn find_and_filter_references( } async fn get_code_contexts( - manager: &Manager, - references_result: &Result, LspManagerError>, + manager: &ApiManager, + references_result: &Result, ApiManagerError>, context_lines: Option, -) -> Result>, LspManagerError> { +) -> Result>, ApiManagerError> { match (references_result, context_lines) { (Ok(refs), Some(lines)) => fetch_code_context(manager, refs.clone(), lines) .await @@ -178,15 +178,15 @@ async fn get_code_contexts( } } -fn handle_lsp_error(e: LspManagerError) -> HttpResponse { +fn handle_lsp_error(e: ApiManagerError) -> HttpResponse { e.into_http_response() } async fn fetch_code_context( - manager: &Manager, + manager: &ApiManager, references: Vec, context_lines: u32, -) -> Result, LspManagerError> { +) -> Result, ApiManagerError> { let mut code_contexts = Vec::new(); for reference in references { let range = lsp_types::Range { diff --git a/crates/wrapper/src/handlers/lsp.rs b/crates/wrapper/src/handlers/lsp.rs index 0a4c5475..a44d368d 100644 --- a/crates/wrapper/src/handlers/lsp.rs +++ b/crates/wrapper/src/handlers/lsp.rs @@ -31,7 +31,7 @@ pub async fn lsp( debug!("LSP request: {:?}", &lsp_req); // Forward the request to the LSP server - match app_state.manager.lsp(lsp_req).await { + match app_state.api_manager.lsp(lsp_req).await { Ok(response) => { info!("Received process response: id={:?}", response.id); debug!("Process response: {:?}", response); diff --git a/crates/wrapper/src/lib.rs b/crates/wrapper/src/lib.rs index 78d70927..15f35bd7 100644 --- a/crates/wrapper/src/lib.rs +++ b/crates/wrapper/src/lib.rs @@ -4,10 +4,10 @@ // Local modules pub mod handlers; pub mod lsp; -pub mod manager; +pub mod managers; // Re-export commonly used types -pub use manager::Manager; +pub use managers::api::ApiManager; // Test utilities (available in test builds) #[cfg(test)] @@ -15,5 +15,5 @@ pub mod test_utils; // AppState struct (shared between lib and bin) pub struct AppState { - pub manager: Manager, + pub api_manager: ApiManager, } diff --git a/crates/wrapper/src/lsp/mod.rs b/crates/wrapper/src/lsp/mod.rs index 2d1e7962..ce757e92 100644 --- a/crates/wrapper/src/lsp/mod.rs +++ b/crates/wrapper/src/lsp/mod.rs @@ -1,3 +1,4 @@ +/// Local LSP client. This module is responsible for communication with the local LSP server. pub mod client; pub mod json_rpc; pub mod languages; diff --git a/crates/wrapper/src/main.rs b/crates/wrapper/src/main.rs index 8f09d5bb..8d673d5e 100644 --- a/crates/wrapper/src/main.rs +++ b/crates/wrapper/src/main.rs @@ -7,7 +7,7 @@ const DEFAULT_RBENV_ROOT: &str = "/opt/rbenv"; mod handlers; mod lsp; -mod manager; +mod managers; use common::utils::workspace_documents::{ DidOpenConfiguration, CSHARP_FILE_PATTERNS, C_AND_CPP_FILE_PATTERNS, GOLANG_FILE_PATTERNS, @@ -17,7 +17,7 @@ use common::utils::workspace_documents::{ use lsp::client::LspClient; use lsp::languages::{GenericLspClient, GoplsClient, SorbetClient}; use lsp::process::ProcessHandler; -use manager::Manager; +use managers::api::ApiManager; /// HTTP wrapper for LSP servers /// Provides HTTP endpoints for LSP JSON-RPC communication @@ -44,7 +44,7 @@ struct Args { /// Application state shared across handlers pub struct AppState { - pub manager: Manager, + pub api_manager: ApiManager, } /// Health check endpoint - simple version that just returns OK @@ -292,9 +292,10 @@ async fn main() -> std::io::Result<()> { info!("LSP server started and initialized successfully"); - let manager = Manager::new(Arc::new(tokio::sync::Mutex::new(client))); + let lsp_client = Arc::new(tokio::sync::Mutex::new(client)); + let api_manager = ApiManager::new(lsp_client); - let app_state = web::Data::new(AppState { manager }); + let app_state = web::Data::new(AppState { api_manager }); // Start HTTP server HttpServer::new(move || { diff --git a/crates/wrapper/src/manager.rs b/crates/wrapper/src/managers/api.rs similarity index 84% rename from crates/wrapper/src/manager.rs rename to crates/wrapper/src/managers/api.rs index 5d05a05a..5d0d28e6 100644 --- a/crates/wrapper/src/manager.rs +++ b/crates/wrapper/src/managers/api.rs @@ -15,7 +15,7 @@ use thiserror::Error; use tokio::sync::Mutex; #[derive(Error, Debug)] -pub enum LspManagerError { +pub enum ApiManagerError { #[error("File not found: {0}")] FileNotFound(String), @@ -38,7 +38,8 @@ pub enum LspManagerError { NotImplemented(String), } -pub struct Manager { +/// Threadsafe manager that maps API calls to invocations to the local LSP server. +pub struct ApiManager { // Box for polymorphism - supports any language client // Mutex for interior mutability (LSP client needs &mut self) // Arc for shared ownership across actix-web handlers @@ -46,7 +47,7 @@ pub struct Manager { ast_grep: AstGrepClient, } -impl Manager { +impl ApiManager { pub fn new(client: Arc>>) -> Self { Self { client, @@ -57,11 +58,11 @@ impl Manager { pub async fn get_file_identifiers( &self, file_path: &str, - ) -> Result, LspManagerError> { + ) -> Result, ApiManagerError> { let full_path = get_mount_dir().join(file_path); if !full_path.exists() { - return Err(LspManagerError::FileNotFound(file_path.to_string())); + return Err(ApiManagerError::FileNotFound(file_path.to_string())); } let full_path_str = full_path.to_str().unwrap_or_default(); @@ -70,7 +71,7 @@ impl Manager { .get_file_identifiers(full_path_str) .await .map_err(|e| { - LspManagerError::InternalError(format!("Symbol retrieval failed: {}", e)) + ApiManagerError::InternalError(format!("Symbol retrieval failed: {}", e)) })?; Ok(ast_grep_result.into_iter().map(|s| s.into()).collect()) @@ -79,11 +80,11 @@ impl Manager { pub async fn get_definitions_in_file( &self, file_path: &str, - ) -> Result, LspManagerError> { + ) -> Result, ApiManagerError> { let full_path = get_mount_dir().join(file_path); if !full_path.exists() { - return Err(LspManagerError::FileNotFound(file_path.to_string())); + return Err(ApiManagerError::FileNotFound(file_path.to_string())); } let full_path_str = full_path.to_str().unwrap_or_default(); @@ -92,7 +93,7 @@ impl Manager { .get_definitions_in_file(full_path_str) .await .map_err(|e| { - LspManagerError::InternalError(format!("Symbol retrieval failed: {}", e)) + ApiManagerError::InternalError(format!("Symbol retrieval failed: {}", e)) })?; Ok(ast_grep_result) @@ -102,7 +103,7 @@ impl Manager { &self, file_path: &str, identifier_position: &lsp_types::Position, - ) -> Result { + ) -> Result { let full_path = get_mount_dir().join(file_path); let full_path_str = full_path.to_str().unwrap_or_default(); match self @@ -111,7 +112,7 @@ impl Manager { .await { Ok(ast_grep_symbol) => Ok(Symbol::from(ast_grep_symbol)), - Err(e) => Err(LspManagerError::InternalError(e.to_string())), + Err(e) => Err(ApiManagerError::InternalError(e.to_string())), } } @@ -119,11 +120,11 @@ impl Manager { &self, file_path: &str, position: Position, - ) -> Result { + ) -> Result { let full_path = get_mount_dir().join(file_path); if !full_path.exists() { - return Err(LspManagerError::FileNotFound(file_path.to_string())); + return Err(ApiManagerError::FileNotFound(file_path.to_string())); } let full_path_str = full_path.to_str().unwrap_or_default(); @@ -133,7 +134,7 @@ impl Manager { .text_document_definition(full_path_str, position) .await .map_err(|e| { - LspManagerError::InternalError(format!("Definition retrieval failed: {}", e)) + ApiManagerError::InternalError(format!("Definition retrieval failed: {}", e)) })?; // Sort the locations if there are multiple @@ -173,11 +174,11 @@ impl Manager { &self, file_path: &str, position: Position, - ) -> Result, LspManagerError> { + ) -> Result, ApiManagerError> { let full_path = get_mount_dir().join(file_path); if !full_path.exists() { - return Err(LspManagerError::FileNotFound(file_path.to_string())); + return Err(ApiManagerError::FileNotFound(file_path.to_string())); } let full_path_str = full_path.to_str().unwrap_or_default(); @@ -187,7 +188,7 @@ impl Manager { .text_document_reference(full_path_str, position) .await .map_err(|e| { - LspManagerError::InternalError(format!("References retrieval failed: {}", e)) + ApiManagerError::InternalError(format!("References retrieval failed: {}", e)) })?; // Sort locations @@ -208,11 +209,11 @@ impl Manager { file_path: &str, position: Position, full_scan: bool, - ) -> Result, LspManagerError> { + ) -> Result, ApiManagerError> { let full_path = get_mount_dir().join(file_path); if !full_path.exists() { - return Err(LspManagerError::FileNotFound(file_path.to_string())); + return Err(ApiManagerError::FileNotFound(file_path.to_string())); } let full_path_str = full_path.to_str().unwrap_or_default(); @@ -224,7 +225,7 @@ impl Manager { { Ok(result) => result, Err(e) => { - return Err(LspManagerError::InternalError(format!( + return Err(ApiManagerError::InternalError(format!( "Failed to find referenced symbols, {}", e ))); @@ -256,7 +257,7 @@ impl Manager { // Only return an error if we couldn't get any definitions at all if definitions.is_empty() && !references_to_symbols.is_empty() { - return Err(LspManagerError::InternalError( + return Err(ApiManagerError::InternalError( "Failed to retrieve any definitions for the referenced symbols".to_string(), )); } @@ -268,7 +269,7 @@ impl Manager { &self, file_path: &str, range: Option, - ) -> Result { + ) -> Result { let full_path = get_mount_dir().join(file_path); let mut locked_client = self.client.lock().await; locked_client @@ -276,7 +277,7 @@ impl Manager { .read_text_document(&full_path, range) .await .map_err(|e| { - LspManagerError::InternalError(format!("Source code retrieval failed: {}", e)) + ApiManagerError::InternalError(format!("Source code retrieval failed: {}", e)) }) } @@ -288,10 +289,10 @@ impl Manager { /// Forward a raw LSP JSON-RPC request to the LSP server /// /// This provides lightweight pass-through of JSON-RPC requests with minimal processing. - pub async fn lsp(&self, request: JsonRpcMessage) -> Result { + pub async fn lsp(&self, request: JsonRpcMessage) -> Result { let req_id = request.id; let Some(method) = request.method else { - return Err(LspManagerError::BadRequest("missing method".to_string())); + return Err(ApiManagerError::BadRequest("missing method".to_string())); }; // Forward to LSP server @@ -301,7 +302,7 @@ impl Manager { .await .map_err(|e| { error!("Failed to forward LSP request: {}", e); - LspManagerError::InternalError(format!("LSP request failed: {}", e)) + ApiManagerError::InternalError(format!("LSP request failed: {}", e)) })?; // Build response @@ -312,13 +313,13 @@ impl Manager { } // Convert from common LspError to wrapper-specific LspManagerError -impl From for LspManagerError { +impl From for ApiManagerError { fn from(err: common::error::LspError) -> Self { use common::error::LspError as CommonError; match &err { - CommonError::FileNotFound(s) => LspManagerError::FileNotFound(s.clone()), - CommonError::UnsupportedFileType(s) => LspManagerError::UnsupportedFileType(s.clone()), - _ => LspManagerError::InternalError(err.to_string()), + CommonError::FileNotFound(s) => ApiManagerError::FileNotFound(s.clone()), + CommonError::UnsupportedFileType(s) => ApiManagerError::UnsupportedFileType(s.clone()), + _ => ApiManagerError::InternalError(err.to_string()), } } } diff --git a/crates/wrapper/src/managers/mod.rs b/crates/wrapper/src/managers/mod.rs new file mode 100644 index 00000000..e5fdf85e --- /dev/null +++ b/crates/wrapper/src/managers/mod.rs @@ -0,0 +1 @@ +pub mod api; From ae72e4e023d23ac11c8a91914013e440df0bb218 Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Mon, 1 Dec 2025 14:46:05 +0100 Subject: [PATCH 38/92] Rename HttpClient to ApiClient --- crates/proxy/src/container/http_client.rs | 117 ------------------ crates/proxy/src/container/mod.rs | 4 +- crates/proxy/src/handlers/container_proxy.rs | 16 +-- .../proxy/src/handlers/definitions_in_file.rs | 2 +- crates/proxy/src/handlers/find_definition.rs | 4 +- crates/proxy/src/handlers/find_identifier.rs | 19 +-- .../src/handlers/find_referenced_symbols.rs | 2 +- crates/proxy/src/handlers/find_references.rs | 2 +- crates/proxy/src/handlers/lsp.rs | 4 +- 9 files changed, 29 insertions(+), 141 deletions(-) delete mode 100644 crates/proxy/src/container/http_client.rs diff --git a/crates/proxy/src/container/http_client.rs b/crates/proxy/src/container/http_client.rs deleted file mode 100644 index 0730cf04..00000000 --- a/crates/proxy/src/container/http_client.rs +++ /dev/null @@ -1,117 +0,0 @@ -/// HTTP client for communicating with LSP wrapper containers -/// -/// This client provides a simple interface to make HTTP requests to language -/// server containers, replacing the direct LSP process management. -use common::api_types::*; -use std::error::Error; - -pub struct ContainerHttpClient { - base_url: String, - client: reqwest::Client, -} - -impl ContainerHttpClient { - pub fn new(endpoint: &str) -> Self { - Self { - base_url: endpoint.to_string(), - client: reqwest::Client::new(), - } - } - - /// Find definition for a symbol - pub async fn find_definition( - &self, - request: &FindDefinitionRequest, - ) -> Result> { - let url = format!("{}/symbol/find-definition", self.base_url); - let response = self.client.post(&url).json(request).send().await?; - - if !response.status().is_success() { - let error_text = response.text().await?; - return Err(format!("Definition request failed: {}", error_text).into()); - } - - Ok(response.json().await?) - } - - /// Find references for a symbol - pub async fn find_references( - &self, - request: &FindReferencesRequest, - ) -> Result> { - let url = format!("{}/symbol/find-references", self.base_url); - let response = self.client.post(&url).json(request).send().await?; - - if !response.status().is_success() { - let error_text = response.text().await?; - return Err(format!("References request failed: {}", error_text).into()); - } - - Ok(response.json().await?) - } - - /// Find identifier by name and optional position - pub async fn find_identifier( - &self, - request: &FindIdentifierRequest, - ) -> Result> { - let url = format!("{}/symbol/find-identifier", self.base_url); - let response = self.client.post(&url).json(request).send().await?; - - if !response.status().is_success() { - let error_text = response.text().await?; - return Err(format!("Find identifier request failed: {}", error_text).into()); - } - - Ok(response.json().await?) - } - - /// Find referenced symbols within a function - pub async fn find_referenced_symbols( - &self, - request: &FindReferencedSymbolsRequest, - ) -> Result> { - let url = format!("{}/symbol/find-referenced-symbols", self.base_url); - let response = self.client.post(&url).json(request).send().await?; - - if !response.status().is_success() { - let error_text = response.text().await?; - return Err(format!("Find referenced symbols request failed: {}", error_text).into()); - } - - Ok(response.json().await?) - } - - /// Get all definitions in a file - pub async fn definitions_in_file( - &self, - request: &DefinitionsInFileRequest, - ) -> Result, Box> { - let url = format!("{}/symbol/definitions-in-file", self.base_url); - let response = self.client.get(&url).query(request).send().await?; - - if !response.status().is_success() { - let error_text = response.text().await?; - return Err(format!("Definitions in file request failed: {}", error_text).into()); - } - - // Response is directly Vec - Ok(response.json().await?) - } - - /// Forward a raw LSP JSON-RPC request to the container - pub async fn lsp( - &self, - request: &JsonRpcMessage, - ) -> Result> { - let url = format!("{}/lsp", self.base_url); - let response = self.client.post(&url).json(request).send().await?; - - if !response.status().is_success() { - let error_text = response.text().await?; - return Err(format!("LSP request failed: {}", error_text).into()); - } - - Ok(response.json().await?) - } -} diff --git a/crates/proxy/src/container/mod.rs b/crates/proxy/src/container/mod.rs index 8f51e272..0fa332a7 100644 --- a/crates/proxy/src/container/mod.rs +++ b/crates/proxy/src/container/mod.rs @@ -5,7 +5,7 @@ use tokio::sync::Mutex; use common::api_types::{LanguageVariant, SupportedLanguages}; -pub mod http_client; +pub mod api_client; pub mod language_manager; pub mod orchestrator; @@ -134,7 +134,7 @@ pub fn language_image_ghcr(language: &SupportedLanguages) -> String { ) } -pub use http_client::ContainerHttpClient; +pub use api_client::ContainerApiClient; #[derive(Debug, Clone)] pub struct ContainerInfo { diff --git a/crates/proxy/src/handlers/container_proxy.rs b/crates/proxy/src/handlers/container_proxy.rs index 733132b7..7c934712 100644 --- a/crates/proxy/src/handlers/container_proxy.rs +++ b/crates/proxy/src/handlers/container_proxy.rs @@ -1,4 +1,4 @@ -use crate::container::{ContainerHttpClient, ContainerOrchestrator}; +use crate::container::{ContainerApiClient, ContainerOrchestrator}; /// Helper module for routing requests to containerized LSP servers /// /// This module handles: @@ -12,13 +12,13 @@ use log::{error, info}; use std::sync::Arc; /// Get or spawn a container for the given language and return an HTTP client -pub async fn get_container_client( +pub async fn get_container_api_client( orchestrator: &Arc, language: SupportedLanguages, -) -> Result { +) -> Result { // Check if container already exists if let Some(container_info) = orchestrator.get_container(&language).await { - return Ok(ContainerHttpClient::new(&container_info.endpoint)); + return Ok(ContainerApiClient::new(&container_info.endpoint)); } // Spawn new container @@ -29,7 +29,7 @@ pub async fn get_container_client( "Container spawned for {:?}: {}", language, container_info.endpoint ); - Ok(ContainerHttpClient::new(&container_info.endpoint)) + Ok(ContainerApiClient::new(&container_info.endpoint)) } Err(e) => { error!("Failed to spawn container for {:?}: {}", language, e); @@ -39,12 +39,12 @@ pub async fn get_container_client( } /// Detect language from file path and get/spawn appropriate container client -pub async fn get_client_for_file( +pub async fn get_api_client_for_file( orchestrator: &Arc, file_path: &str, -) -> Result { +) -> Result { let language = detect_language(file_path) .map_err(|e| format!("Failed to detect language for {}: {}", file_path, e))?; - get_container_client(orchestrator, language).await + get_container_api_client(orchestrator, language).await } diff --git a/crates/proxy/src/handlers/definitions_in_file.rs b/crates/proxy/src/handlers/definitions_in_file.rs index d16c8af6..947115ea 100644 --- a/crates/proxy/src/handlers/definitions_in_file.rs +++ b/crates/proxy/src/handlers/definitions_in_file.rs @@ -28,7 +28,7 @@ pub async fn definitions_in_file( // Get container client for this file's language let client = - match container_proxy::get_client_for_file(&data.orchestrator, &info.file_path).await { + match container_proxy::get_api_client_for_file(&data.orchestrator, &info.file_path).await { Ok(client) => client, Err(e) => { error!("Failed to get container client: {}", e); diff --git a/crates/proxy/src/handlers/find_definition.rs b/crates/proxy/src/handlers/find_definition.rs index 91648da8..2729e15f 100644 --- a/crates/proxy/src/handlers/find_definition.rs +++ b/crates/proxy/src/handlers/find_definition.rs @@ -28,7 +28,9 @@ pub async fn find_definition( // Get container client for this file's language let client = - match container_proxy::get_client_for_file(&data.orchestrator, &info.position.path).await { + match container_proxy::get_api_client_for_file(&data.orchestrator, &info.position.path) + .await + { Ok(client) => client, Err(e) => { error!("Failed to get container client: {}", e); diff --git a/crates/proxy/src/handlers/find_identifier.rs b/crates/proxy/src/handlers/find_identifier.rs index a0dfd5fa..80228941 100644 --- a/crates/proxy/src/handlers/find_identifier.rs +++ b/crates/proxy/src/handlers/find_identifier.rs @@ -27,15 +27,16 @@ pub async fn find_identifier( ); // Get container client for this file's language - let client = match container_proxy::get_client_for_file(&data.orchestrator, &info.path).await { - Ok(client) => client, - Err(e) => { - error!("Failed to get container client: {}", e); - return HttpResponse::InternalServerError().json(ErrorResponse { - error: format!("Failed to get container client: {}", e), - }); - } - }; + let client = + match container_proxy::get_api_client_for_file(&data.orchestrator, &info.path).await { + Ok(client) => client, + Err(e) => { + error!("Failed to get container client: {}", e); + return HttpResponse::InternalServerError().json(ErrorResponse { + error: format!("Failed to get container client: {}", e), + }); + } + }; // Forward request to container match client.find_identifier(&info.into_inner()).await { diff --git a/crates/proxy/src/handlers/find_referenced_symbols.rs b/crates/proxy/src/handlers/find_referenced_symbols.rs index 9a81d4a5..2485d9e3 100644 --- a/crates/proxy/src/handlers/find_referenced_symbols.rs +++ b/crates/proxy/src/handlers/find_referenced_symbols.rs @@ -31,7 +31,7 @@ pub async fn find_referenced_symbols( ); // Get container client for this file's language - let client = match container_proxy::get_client_for_file( + let client = match container_proxy::get_api_client_for_file( &data.orchestrator, &info.identifier_position.path, ) diff --git a/crates/proxy/src/handlers/find_references.rs b/crates/proxy/src/handlers/find_references.rs index 8bdeef55..873d40fd 100644 --- a/crates/proxy/src/handlers/find_references.rs +++ b/crates/proxy/src/handlers/find_references.rs @@ -29,7 +29,7 @@ pub async fn find_references( ); // Get container client for this file's language - let client = match container_proxy::get_client_for_file( + let client = match container_proxy::get_api_client_for_file( &data.orchestrator, &info.identifier_position.path, ) diff --git a/crates/proxy/src/handlers/lsp.rs b/crates/proxy/src/handlers/lsp.rs index d9408980..761ff637 100644 --- a/crates/proxy/src/handlers/lsp.rs +++ b/crates/proxy/src/handlers/lsp.rs @@ -91,7 +91,9 @@ pub async fn lsp(data: Data, request: Json) -> HttpRes }; // Get container client for this file's language - let client = match container_proxy::get_client_for_file(&data.orchestrator, &file_path).await { + let client = match container_proxy::get_api_client_for_file(&data.orchestrator, &file_path) + .await + { Ok(client) => client, Err(e) => { error!("Failed to get container client: {}", e); From 9641c02de86ddd7b7a81b6fdd9ffc06f8dfdcc14 Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Mon, 1 Dec 2025 15:14:58 +0100 Subject: [PATCH 39/92] Add agent instructions --- AGENTS.md | 27 +++++++++++++++++++++++++++ CLAUDE.md | 1 + 2 files changed, 28 insertions(+) create mode 100644 AGENTS.md create mode 120000 CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..0e38304b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,27 @@ +# Coding Agent Instructions for nuanced-dev/lsp + +This repo contains a containerized LSP implementation. + +Main code directories: + +- `clients`: clients for different languages +- `crates`: the main Rust code for the proxy (front-end) and the wrapper (per-language back-end) +- `dockerfiles`: the Dockerfiles for the Rust containers and language-specific containers + +## Rust crates + +The code is managed with Cargo. A typical workflow: + +- Verify code with `cargo build` +- Once done, format code with `cargo fmt` + +Do not run tests unless explicitly asked! + +## TypeScript client + +The code is managed with NPM. A typical workflow: + +- Verify code with `npm run build` +- Once done, format and lint with `npm run lint:fix` + +Do not run tests unless explicitly asked! diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 00000000..47dc3e3d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file From 158a14d9572d41e6dba66e1d42f70937f0d10332 Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Mon, 1 Dec 2025 17:50:21 +0100 Subject: [PATCH 40/92] Also add new api_client module --- crates/proxy/src/container/api_client.rs | 121 +++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 crates/proxy/src/container/api_client.rs diff --git a/crates/proxy/src/container/api_client.rs b/crates/proxy/src/container/api_client.rs new file mode 100644 index 00000000..33432843 --- /dev/null +++ b/crates/proxy/src/container/api_client.rs @@ -0,0 +1,121 @@ +use common::api_types::*; +use std::error::Error; + +/// HTTP client for calling API methods on LSP wrapper containers +/// +/// This client provides a simple interface to make HTTP requests to language +/// server containers, replacing the direct LSP process management. +pub struct ContainerApiClient { + base_url: String, + client: reqwest::Client, +} + +impl ContainerApiClient { + pub fn new(endpoint: &str) -> Self { + Self { + base_url: endpoint.to_string(), + client: reqwest::Client::new(), + } + } + + pub fn get_base_url(&self) -> &str { + &self.base_url + } + + /// Find definition for a symbol + pub async fn find_definition( + &self, + request: &FindDefinitionRequest, + ) -> Result> { + let url = format!("{}/symbol/find-definition", self.base_url); + let response = self.client.post(&url).json(request).send().await?; + + if !response.status().is_success() { + let error_text = response.text().await?; + return Err(format!("Definition request failed: {}", error_text).into()); + } + + Ok(response.json().await?) + } + + /// Find references for a symbol + pub async fn find_references( + &self, + request: &FindReferencesRequest, + ) -> Result> { + let url = format!("{}/symbol/find-references", self.base_url); + let response = self.client.post(&url).json(request).send().await?; + + if !response.status().is_success() { + let error_text = response.text().await?; + return Err(format!("References request failed: {}", error_text).into()); + } + + Ok(response.json().await?) + } + + /// Find identifier by name and optional position + pub async fn find_identifier( + &self, + request: &FindIdentifierRequest, + ) -> Result> { + let url = format!("{}/symbol/find-identifier", self.base_url); + let response = self.client.post(&url).json(request).send().await?; + + if !response.status().is_success() { + let error_text = response.text().await?; + return Err(format!("Find identifier request failed: {}", error_text).into()); + } + + Ok(response.json().await?) + } + + /// Find referenced symbols within a function + pub async fn find_referenced_symbols( + &self, + request: &FindReferencedSymbolsRequest, + ) -> Result> { + let url = format!("{}/symbol/find-referenced-symbols", self.base_url); + let response = self.client.post(&url).json(request).send().await?; + + if !response.status().is_success() { + let error_text = response.text().await?; + return Err(format!("Find referenced symbols request failed: {}", error_text).into()); + } + + Ok(response.json().await?) + } + + /// Get all definitions in a file + pub async fn definitions_in_file( + &self, + request: &DefinitionsInFileRequest, + ) -> Result, Box> { + let url = format!("{}/symbol/definitions-in-file", self.base_url); + let response = self.client.get(&url).query(request).send().await?; + + if !response.status().is_success() { + let error_text = response.text().await?; + return Err(format!("Definitions in file request failed: {}", error_text).into()); + } + + // Response is directly Vec + Ok(response.json().await?) + } + + /// Forward a raw LSP JSON-RPC request to the container + pub async fn lsp( + &self, + request: &JsonRpcMessage, + ) -> Result> { + let url = format!("{}/lsp", self.base_url); + let response = self.client.post(&url).json(request).send().await?; + + if !response.status().is_success() { + let error_text = response.text().await?; + return Err(format!("LSP request failed: {}", error_text).into()); + } + + Ok(response.json().await?) + } +} From 5657a75b50f268035e13511faa33c434734a0a75 Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Mon, 1 Dec 2025 20:52:41 +0100 Subject: [PATCH 41/92] Implement websocket communication --- AGENTS.md | 11 + Cargo.lock | 67 +++++ Cargo.toml | 1 + clients/typescript/package.json | 2 + clients/typescript/src/client.ts | 32 +- clients/typescript/src/server.ts | 161 +++++----- clients/typescript/src/types.ts | 11 +- crates/proxy/Cargo.toml | 2 + crates/proxy/src/handlers/lsp.rs | 47 ++- crates/proxy/src/handlers/lsp_ws.rs | 308 ++++++++++++++++++++ crates/proxy/src/handlers/mod.rs | 3 +- crates/proxy/src/lib.rs | 8 +- crates/wrapper/Cargo.toml | 2 + crates/wrapper/src/handlers/lsp_ws.rs | 29 ++ crates/wrapper/src/handlers/mod.rs | 1 + crates/wrapper/src/lsp/client.rs | 43 ++- crates/wrapper/src/lsp/languages/generic.rs | 15 +- crates/wrapper/src/lsp/languages/golang.rs | 8 + crates/wrapper/src/lsp/languages/sorbet.rs | 9 +- crates/wrapper/src/main.rs | 1 + crates/wrapper/src/managers/api.rs | 5 + crates/wrapper/src/managers/mod.rs | 1 + crates/wrapper/src/managers/ws.rs | 173 +++++++++++ 23 files changed, 786 insertions(+), 154 deletions(-) create mode 100644 crates/proxy/src/handlers/lsp_ws.rs create mode 100644 crates/wrapper/src/handlers/lsp_ws.rs create mode 100644 crates/wrapper/src/managers/ws.rs diff --git a/AGENTS.md b/AGENTS.md index 0e38304b..96fbaf90 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,9 +19,20 @@ Do not run tests unless explicitly asked! ## TypeScript client +All the code is typed. Ensure the generated code is also typed. It should not be necessary to use `any` or `unknown` types. + The code is managed with NPM. A typical workflow: - Verify code with `npm run build` - Once done, format and lint with `npm run lint:fix` +Add new dependencies through NPM. + +- Install a runtime dependency with `npm install PKG_NAME` +- Install a dev dependency with `npm install --save-dev PKG_NAME` + +If there are module import errors, ensure all dependencies are installed first, before modifying the code. + +- Install all dependencies with `npm install` + Do not run tests unless explicitly asked! diff --git a/Cargo.lock b/Cargo.lock index e14a32cc..43804ff8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -223,6 +223,20 @@ dependencies = [ "syn", ] +[[package]] +name = "actix-ws" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3a1fb4f9f2794b0aadaf2ba5f14a6f034c7e86957b458c506a8cb75953f2d99" +dependencies = [ + "actix-codec", + "actix-http", + "actix-web", + "bytestring", + "futures-core", + "tokio", +] + [[package]] name = "adler2" version = "2.0.1" @@ -627,6 +641,12 @@ version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.10.1" @@ -968,6 +988,12 @@ dependencies = [ "parking_lot_core", ] +[[package]] +name = "data-encoding" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" + [[package]] name = "deranged" version = "0.5.5" @@ -2418,6 +2444,7 @@ dependencies = [ "actix-cors", "actix-files", "actix-web", + "actix-ws", "async-trait", "bollard", "clap", @@ -2447,6 +2474,7 @@ dependencies = [ "tempfile", "thiserror 1.0.69", "tokio", + "tokio-tungstenite", "tower 0.4.13", "tracing", "tracing-subscriber", @@ -3369,6 +3397,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-tungstenite" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c83b561d025642014097b66e6c1bb422783339e0909e4429cde4749d1990bc38" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + [[package]] name = "tokio-util" version = "0.7.17" @@ -3777,6 +3817,25 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ef1a641ea34f399a848dea702823bbecfb4c486f911735368f1f137cb8257e1" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http 1.3.1", + "httparse", + "log", + "rand 0.8.5", + "sha1", + "thiserror 1.0.69", + "url", + "utf-8", +] + [[package]] name = "typenum" version = "1.19.0" @@ -3849,6 +3908,12 @@ dependencies = [ "serde", ] +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -4373,11 +4438,13 @@ name = "wrapper" version = "0.5.0" dependencies = [ "actix-web", + "actix-ws", "ast-grep", "async-trait", "clap", "common", "env_logger", + "futures", "glob", "ignore", "log", diff --git a/Cargo.toml b/Cargo.toml index 1234312d..52669a23 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ authors = ["Nuanced"] [workspace.dependencies] # Shared dependencies actix-web = "4.0" +actix-ws = "0.3" ast-grep = { version = "0.39", default-features = false } tokio = { version = "1.0", features = ["full", "process"] } serde = { version = "1.0", features = ["derive"] } diff --git a/clients/typescript/package.json b/clients/typescript/package.json index c7862613..5ba14843 100644 --- a/clients/typescript/package.json +++ b/clients/typescript/package.json @@ -38,6 +38,7 @@ "commander": "^12.1.0", "fastest-levenshtein": "^1.0.16", "micromatch": "^4.0.8", + "ws": "^8.18.0", "zod": "^3.23.8" }, "devDependencies": { @@ -45,6 +46,7 @@ "@stylistic/eslint-plugin": "^5.4.0", "@types/micromatch": "^4.0.9", "@types/node": "^20.19.25", + "@types/ws": "^8.5.13", "esbuild": "^0.25.9", "eslint": "^9.33.0", "eslint-config-prettier": "^10.1.5", diff --git a/clients/typescript/src/client.ts b/clients/typescript/src/client.ts index 0205bf50..a7ee7663 100644 --- a/clients/typescript/src/client.ts +++ b/clients/typescript/src/client.ts @@ -1,3 +1,4 @@ +import type WebSocket from "ws"; import type { DefinitionsInFileResult, DockerResult, @@ -19,8 +20,7 @@ import type { StatusResult, LogsResult, PullResult, - JsonRpcRequest, - JsonRpcResponse, + JsonRpcMessage, } from "./types.js"; import { @@ -37,21 +37,13 @@ import { err, ok } from "./types.js"; const TRAILING_SLASH_RE = /\/$/; type HttpModule = typeof import("./http.js"); -let _http: HttpModule | null = null; async function http(): Promise { - // Lazy-load only when a data-plane method is called - if (_http) return _http; - _http = await import("./http.js"); - return _http!; + return import("./http.js"); } type ProxyModule = typeof import("./proxy.js"); -let _proxy: ProxyModule | null = null; async function proxy(): Promise { - // Lazy-load only when a docker lifecycle method is called - if (_proxy) return _proxy; - _proxy = await import("./proxy.js"); - return _proxy!; + return await import("./proxy.js"); } export class NuancedLspClient { @@ -135,6 +127,16 @@ export class NuancedLspClient { return this.fullLsProxyUrl; } + /** + * Create a WebSocket connection for bidirectional LSP communication + */ + async lsp_ws(): Promise { + const httpUrl = await this.lsProxyUrl(); + const wsUrl = httpUrl.replace(/^http/, "ws"); + const WS = (await import("ws")).default; + return new WS(`${wsUrl}/lsp/ws`); + } + // ---- Lifecycle (docker) --------------------------------------------------- async up( workspace: string, @@ -412,13 +414,13 @@ export class NuancedLspClient { const url = await this.lsProxyUrl(); const { httpRequestWithRetries } = await http(); const id = this.nextId++; - const request: JsonRpcRequest = { + const request: JsonRpcMessage = { jsonrpc: "2.0", id, method, params, }; - const result = await httpRequestWithRetries( + const result = await httpRequestWithRetries( "POST", "/lsp", url, @@ -451,7 +453,7 @@ export class NuancedLspClient { ): Promise> { const url = await this.lsProxyUrl(); const { httpRequestWithRetries } = await http(); - const request: JsonRpcRequest = { + const request: JsonRpcMessage = { jsonrpc: "2.0", method, params, diff --git a/clients/typescript/src/server.ts b/clients/typescript/src/server.ts index ba509269..05d95d9c 100644 --- a/clients/typescript/src/server.ts +++ b/clients/typescript/src/server.ts @@ -1,11 +1,7 @@ import type { Readable, Writable } from "node:stream"; +import WebSocket from "ws"; import type { NuancedLspClient } from "./client.js"; -import type { - JsonRpcMessage, - JsonRpcRequest, - JsonRpcResponse, - ServerCommandOptions, -} from "./types.js"; +import type { JsonRpcMessage, ServerCommandOptions } from "./types.js"; import { isErr, JsonRpcErrorCode } from "./types.js"; // ---- Types ------------------------------------------------------------------ @@ -32,6 +28,7 @@ class LspServer { private readonly logLevel: MessageType; private shutdownReceived = false; private isShuttingDown = false; + private ws: WebSocket | null = null; constructor( client: NuancedLspClient, @@ -89,6 +86,48 @@ class LspServer { ); throw new Error("Failed to start container"); } + + this.ws = await this.client.lsp_ws(); + await this.connectWebSocket(); + } + + private async connectWebSocket(): Promise { + return new Promise((resolve, reject) => { + if (!this.ws) { + reject(new Error("WebSocket not initialized")); + return; + } + + this.ws.on("open", () => { + this.sendLogMessage(MessageType.Debug, "WebSocket connected"); + resolve(); + }); + + this.ws.on("error", (err: Error) => { + this.sendLogMessage( + MessageType.Error, + `WebSocket error: ${err.message}`, + ); + reject(err); + }); + + this.ws.on("close", () => { + this.sendLogMessage(MessageType.Debug, "WebSocket closed"); + }); + + this.ws.on("message", (data: Buffer | string) => { + try { + const message = JSON.parse(data.toString()) as JsonRpcMessage; + // Forward server notifications to stdout + writeMessage(this.output, message); + } catch (err) { + this.sendLogMessage( + MessageType.Error, + `Failed to parse WebSocket message: ${err}`, + ); + } + }); + }); } private setupSignalHandlers(): void { @@ -104,6 +143,11 @@ class LspServer { if (this.isShuttingDown) return; this.isShuttingDown = true; + if (this.ws) { + this.ws.close(); + this.ws = null; + } + await this.client.down(); } @@ -125,7 +169,7 @@ class LspServer { } } - private async handleShutdownRequest(message: JsonRpcRequest): Promise { + private async handleShutdownRequest(message: JsonRpcMessage): Promise { await this.sendLogMessage( MessageType.Debug, "Nuanced LSP shutdown request received", @@ -134,7 +178,7 @@ class LspServer { this.shutdownReceived = true; await this.stopServer(); - const shutdownResponse: JsonRpcResponse = { + const shutdownResponse: JsonRpcMessage = { jsonrpc: "2.0", id: message.id ?? null, result: null, @@ -152,95 +196,50 @@ class LspServer { process.exit(exitCode); } - private async forwardMessage(message: JsonRpcRequest): Promise { + private async forwardMessage(message: JsonRpcMessage): Promise { await this.sendLogMessage( MessageType.Debug, `Nuanced LSP request: ${JSON.stringify(message)}`, ); - try { - // Determine if this is a request (has id) or notification (no id) - const isRequest = message.id !== undefined; - const result = isRequest - ? await this.client.request(message.method, message.params, undefined) - : await this.client.notify(message.method, message.params, undefined); - - if (result.ok) { - await this.sendLogMessage( - MessageType.Debug, - `Nuanced LSP result reponse: ${JSON.stringify(result.data)}`, - ); - - // Only send response for requests (notifications don't get responses) - if (isRequest) { - const response: JsonRpcResponse = { - jsonrpc: "2.0", - id: message.id!, - result: result.data, - }; - await writeMessage(this.output, response); - } - } else { - await this.sendLogMessage( - MessageType.Error, - `Nuanced LSP error response: ${JSON.stringify(result.data)}`, - ); - - // Only send error response for requests - if (isRequest) { - // If error is a JsonRpcError (from 500 response), use it directly - // Otherwise create a generic error - let error: { - code: number; - message: string; - data?: any; - }; - if ( - typeof result.data.error === "object" && - result.data.error !== null && - "code" in result.data.error && - "message" in result.data.error - ) { - error = result.data.error as { - code: number; - message: string; - data?: any; - }; - } else { - error = { - code: result.data.status_code ?? JsonRpcErrorCode.InternalError, - message: "LSP forwarding error", - data: result.data.error, - }; - } + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + await this.sendLogMessage(MessageType.Error, "WebSocket not connected"); - const errorResponse: JsonRpcResponse = { - jsonrpc: "2.0", - id: message.id!, - error, - }; - await writeMessage(this.output, errorResponse); - } - } - } catch (err) { - // Only send error response for requests - if (message.id !== undefined) { - const errorResponse: JsonRpcResponse = { + if (message.id !== undefined && message.id !== null) { + const errorResponse: JsonRpcMessage = { jsonrpc: "2.0", id: message.id, error: { code: JsonRpcErrorCode.InternalError, - message: "Internal error", - data: String(err), + message: "WebSocket not connected", }, }; await writeMessage(this.output, errorResponse); } + return; + } + try { + this.ws.send(JSON.stringify(message)); + } catch (err) { await this.sendLogMessage( MessageType.Error, `Nuanced LSP forwarding error: ${err}`, ); + + // Only send error response for requests + if (message.id !== undefined && message.id !== null) { + const errorResponse: JsonRpcMessage = { + jsonrpc: "2.0", + id: message.id, + error: { + code: JsonRpcErrorCode.InternalError, + message: "Failed to send message over WebSocket", + data: String(err), + }, + }; + await writeMessage(this.output, errorResponse); + } } } @@ -250,7 +249,7 @@ class LspServer { ): Promise { if (type > this.logLevel) return; - const notification: JsonRpcRequest = { + const notification: JsonRpcMessage = { jsonrpc: "2.0", method: "window/logMessage", params: { @@ -283,7 +282,7 @@ export async function runLspServer( * Read a single JSON-RPC message from stdin with Content-Length header parsing. * Returns null when stdin is closed. */ -async function readMessage(input: Readable): Promise { +async function readMessage(input: Readable): Promise { return new Promise((resolve, reject) => { let contentLength: number | null = null; let buffer = Buffer.alloc(0); @@ -343,7 +342,7 @@ async function readMessage(input: Readable): Promise { .toString("utf8"); try { - const message = JSON.parse(messageContent) as JsonRpcRequest; + const message = JSON.parse(messageContent) as JsonRpcMessage; cleanup(); resolve(message); } catch (err) { diff --git a/clients/typescript/src/types.ts b/clients/typescript/src/types.ts index 6a0f66fa..ba4f6615 100644 --- a/clients/typescript/src/types.ts +++ b/clients/typescript/src/types.ts @@ -302,16 +302,9 @@ export type FindReferencesResult = Named< export interface JsonRpcMessage { jsonrpc: "2.0"; -} - -export interface JsonRpcRequest extends JsonRpcMessage { - id?: string | number; - method: string; + id?: string | number | null; + method?: string; params?: any; -} - -export interface JsonRpcResponse extends JsonRpcMessage { - id: string | number | null; result?: any; error?: JsonRpcError; } diff --git a/crates/proxy/Cargo.toml b/crates/proxy/Cargo.toml index a5868470..584f8f1b 100644 --- a/crates/proxy/Cargo.toml +++ b/crates/proxy/Cargo.toml @@ -14,6 +14,7 @@ path = "src/main.rs" [dependencies] common.workspace = true actix-web.workspace = true +actix-ws.workspace = true actix-files = "0.6" actix-cors = "0.6" tokio.workspace = true @@ -52,6 +53,7 @@ regex = { version = "1.8", features = ["std"] } futures = "0.3" futures-util = "0.3" rand = "0.8" +tokio-tungstenite = "0.21" [dev-dependencies] tempfile.workspace = true diff --git a/crates/proxy/src/handlers/lsp.rs b/crates/proxy/src/handlers/lsp.rs index 761ff637..93cfad32 100644 --- a/crates/proxy/src/handlers/lsp.rs +++ b/crates/proxy/src/handlers/lsp.rs @@ -36,15 +36,11 @@ pub async fn lsp(data: Data, request: Json) -> HttpRes // Handle lifecycle requests locally if let Some(response) = handle_lifecycle_request(&lsp_req) { - return response; + return HttpResponse::Ok().json(response); } // For language feature requests, extract document URI and route to appropriate backend - let document_uri = match lsp_req - .params - .as_ref() - .and_then(|p| extract_document_uri(p)) - { + let document_uri = match extract_document_uri(&lsp_req) { Some(uri) => uri, None => { warn!( @@ -153,8 +149,8 @@ pub async fn lsp(data: Data, request: Json) -> HttpRes } } -/// Handle lifecycle requests locally -fn handle_lifecycle_request(request: &JsonRpcMessage) -> Option { +/// Handle lifecycle requests locally, returning a JSON-RPC response if handled +pub fn handle_lifecycle_request(request: &JsonRpcMessage) -> Option { let req_id = request.id.clone(); let Some(method) = request.method.as_ref() else { return None; @@ -183,7 +179,7 @@ fn handle_lifecycle_request(request: &JsonRpcMessage) -> Option { .into(), ); capabilities.type_definition_provider = Some(true.into()); - let response = JsonRpcMessage::new_result_response( + Some(JsonRpcMessage::new_result_response( req_id, InitializeResult { capabilities, @@ -192,33 +188,30 @@ fn handle_lifecycle_request(request: &JsonRpcMessage) -> Option { version: Some(env!("CARGO_PKG_VERSION").to_string()), }), }, - ); - Some(HttpResponse::Ok().json(response)) - } - "initialized" | "exit" => Some(HttpResponse::Ok().finish()), - "shutdown" => { - let response = JsonRpcMessage::new_result_response(req_id, Value::Null); - Some(HttpResponse::Ok().json(response)) + )) } + "initialized" | "exit" => None, // No response for notifications + "shutdown" => Some(JsonRpcMessage::new_result_response(req_id, Value::Null)), dollar_method if dollar_method.starts_with("$/") => { if req_id.is_some() { - let response = JsonRpcMessage::new_error_response( + Some(JsonRpcMessage::new_error_response( req_id, - JsonRpcErrorCode::MethodNotFound, - "Method not found", - ); - Some(HttpResponse::Ok().json(response)) + JsonRpcErrorCode::MethodNotFound as i32, + "Method not found".to_string(), + )) } else { - Some(HttpResponse::Ok().finish()) + None } } _ => None, } } -/// Extract document URI from JSON-RPC request parameters -fn extract_document_uri(params: &Value) -> Option { - params +/// Extract document URI from JSON-RPC request +pub fn extract_document_uri(request: &JsonRpcMessage) -> Option { + request + .params + .as_ref()? .get("textDocument") .and_then(|td| td.get("uri")) .and_then(|u| u.as_str()) @@ -226,7 +219,7 @@ fn extract_document_uri(params: &Value) -> Option { } /// Recursively convert paths in JSON value from host to container -fn convert_json_paths_host_to_container<'a>( +pub fn convert_json_paths_host_to_container<'a>( orchestrator: &'a Arc, value: &'a mut Value, ) -> std::pin::Pin> + 'a>> { @@ -262,7 +255,7 @@ fn convert_json_paths_recursive(value: &mut Value, from: &str, to: &str) { } /// Recursively convert paths in JSON value from container to host -fn convert_json_paths_container_to_host<'a>( +pub fn convert_json_paths_container_to_host<'a>( orchestrator: &'a Arc, value: &'a mut Value, ) -> std::pin::Pin> + 'a>> { diff --git a/crates/proxy/src/handlers/lsp_ws.rs b/crates/proxy/src/handlers/lsp_ws.rs new file mode 100644 index 00000000..f5276716 --- /dev/null +++ b/crates/proxy/src/handlers/lsp_ws.rs @@ -0,0 +1,308 @@ +use crate::handlers::container_proxy; +use crate::handlers::lsp::{ + convert_json_paths_container_to_host, convert_json_paths_host_to_container, + extract_document_uri, handle_lifecycle_request, +}; +use crate::AppState; +use actix_web::web::{Data, Payload}; +use actix_web::{HttpRequest, HttpResponse}; +use actix_ws::{Message as WsMessage, MessageStream, Session}; +use common::api_types::{JsonRpcMessage, SupportedLanguages}; +use common::utils::language_utils::detect_language; +use futures_util::stream::SplitSink; +use futures_util::{SinkExt, StreamExt}; +use log::{debug, error, info, warn}; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::Mutex; +use tokio_tungstenite::tungstenite::Message as TungsteniteMessage; +use tokio_tungstenite::{MaybeTlsStream, WebSocketStream}; +use url::Url; + +type ContainerWsConnection = WebSocketStream>; +type ContainerWsSink = SplitSink; + +/// WebSocket endpoint for bidirectional LSP communication +pub async fn lsp_ws( + req: HttpRequest, + stream: Payload, + data: Data, +) -> Result { + debug!("WebSocket connection request received at proxy"); + + let (response, session, msg_stream) = actix_ws::handle(&req, stream)?; + + // Spawn task to handle the connection using actix_web::rt (supports !Send futures) + actix_web::rt::spawn(async move { + if let Err(e) = handle_ws_connection(session, msg_stream, data).await { + error!("WebSocket connection error: {}", e); + } + }); + + Ok(response) +} + +async fn handle_ws_connection( + mut client_session: Session, + mut client_stream: MessageStream, + data: Data, +) -> Result<(), Box> { + debug!("Handling WebSocket connection"); + + // Cache of container WebSocket sinks by language (for sending messages to containers) + let container_sinks: Arc>> = + Arc::new(Mutex::new(HashMap::new())); + + // Process incoming messages from client + while let Some(result) = client_stream.next().await { + match result { + Ok(WsMessage::Text(text)) => { + if let Err(e) = handle_client_text_message( + text.to_string(), + &mut client_session, + &data, + &container_sinks, + ) + .await + { + error!("Error handling client text message: {}", e); + break; + } + } + Ok(WsMessage::Ping(bytes)) => { + if let Err(e) = client_session.pong(&bytes).await { + error!("Failed to send pong: {}", e); + break; + } + } + Ok(WsMessage::Pong(_)) => { + debug!("Received pong from client"); + } + Ok(WsMessage::Close(reason)) => { + debug!("Client closed connection: {:?}", reason); + break; + } + Err(e) => { + error!("Error receiving from client: {}", e); + break; + } + _ => {} + } + } + + debug!("WebSocket connection ended"); + Ok(()) +} + +/// Handle a text message from the client +async fn handle_client_text_message( + text: String, + client_session: &mut Session, + data: &Data, + container_sinks: &Arc>>, +) -> Result<(), Box> { + debug!("Client -> Proxy: {}", text); + + // Parse JSON-RPC message + let json_rpc_msg: JsonRpcMessage = serde_json::from_str(&text)?; + + // Handle lifecycle requests locally + if let Some(response) = handle_lifecycle_request(&json_rpc_msg) { + let response_text = serde_json::to_string(&response)?; + client_session.text(response_text).await?; + return Ok(()); + } + + // Extract document URI to determine routing + let document_uri = + extract_document_uri(&json_rpc_msg).ok_or("Could not extract document URI from message")?; + + // Convert file:// URI to path and detect language + let file_path = uri_to_file_path(&document_uri)?; + let language = detect_language(&file_path) + .map_err(|e| format!("Failed to detect language for {}: {}", file_path, e))?; + + debug!( + "Routing message for file {} (language: {:?})", + file_path, language + ); + + // Convert paths and send message to container + send_to_container( + json_rpc_msg, + language, + data, + client_session, + container_sinks, + ) + .await +} + +/// Convert a file:// URI to a file path +fn uri_to_file_path(uri: &str) -> Result> { + let url = Url::parse(uri)?; + if url.scheme() != "file" { + return Err(format!("Expected file URI, got: {}", uri).into()); + } + let path = url + .to_file_path() + .map_err(|_| format!("Invalid file URI path: {}", uri))?; + Ok(path.to_string_lossy().to_string()) +} + +/// Ensure a container connection exists for the given language and return the sink +async fn ensure_container_connection( + language: SupportedLanguages, + data: &Data, + client_session: &Session, + container_sinks: &Arc>>, +) -> Result> { + let mut sinks = container_sinks.lock().await; + + // Return existing sink if available + if sinks.contains_key(&language) { + // We need to remove and return the sink to satisfy borrow checker + // It will be re-inserted by the caller + return Ok(sinks.remove(&language).unwrap()); + } + + // Get container client for this language + let container_client = + container_proxy::get_container_api_client(&data.orchestrator, language.clone()).await?; + + // Connect to container's WebSocket endpoint + let container_ws_url = format!("{}/lsp/ws", container_client.get_base_url()) + .replace("http://", "ws://") + .replace("https://", "wss://"); + + debug!("Connecting to container WebSocket: {}", container_ws_url); + + let (container_ws, _) = tokio_tungstenite::connect_async(&container_ws_url).await?; + + info!("Connected to container WebSocket for {:?}", language); + + // Split WebSocket into read and write halves + let (sink, stream) = container_ws.split(); + + // Spawn a task to read from this connection and forward to client + spawn_container_reader( + language.clone(), + stream, + client_session.clone(), + data.orchestrator.clone(), + ); + + Ok(sink) +} + +/// Spawn a task to read from a container WebSocket and forward messages to the client +fn spawn_container_reader( + language: SupportedLanguages, + stream: futures_util::stream::SplitStream, + mut client_session: Session, + orchestrator: Arc, +) { + actix_web::rt::spawn(async move { + let mut stream = stream; + while let Some(msg_result) = stream.next().await { + match msg_result { + Ok(TungsteniteMessage::Text(text)) => { + debug!("Container -> Client from {:?}: {}", language, text); + + if let Err(e) = forward_container_message_to_client( + text, + &mut client_session, + &orchestrator, + ) + .await + { + error!("Failed to forward container message: {}", e); + break; + } + } + Ok(TungsteniteMessage::Close(_)) => { + debug!("Container WebSocket closed for {:?}", language); + break; + } + Err(e) => { + error!( + "Error reading from container WebSocket for {:?}: {}", + language, e + ); + break; + } + _ => {} + } + } + }); +} + +/// Convert paths in a container message and forward to client +async fn forward_container_message_to_client( + text: String, + client_session: &mut Session, + orchestrator: &Arc, +) -> Result<(), Box> { + // Parse and convert paths + match serde_json::from_str::(&text) { + Ok(mut msg) => { + // Convert result paths + if let Some(ref mut result) = msg.result { + convert_json_paths_container_to_host(orchestrator, result).await?; + } + + // Convert params paths (for notifications) + if let Some(ref mut params) = msg.params { + convert_json_paths_container_to_host(orchestrator, params).await?; + } + + let converted_text = serde_json::to_string(&msg)?; + client_session.text(converted_text).await?; + } + Err(e) => { + warn!("Failed to parse message from container: {}", e); + // Forward as-is if not valid JSON-RPC + client_session.text(text).await?; + } + } + Ok(()) +} + +/// Convert paths in a message and send to container +async fn send_to_container( + json_rpc_msg: JsonRpcMessage, + language: SupportedLanguages, + data: &Data, + client_session: &Session, + container_sinks: &Arc>>, +) -> Result<(), Box> { + // Convert paths in message + let mut converted_msg = json_rpc_msg; + if let Some(ref mut params) = converted_msg.params { + convert_json_paths_host_to_container(&data.orchestrator, params).await?; + } + + // Send to container + let converted_text = serde_json::to_string(&converted_msg)?; + + // Get or create the sink for this language + let mut sink = + ensure_container_connection(language.clone(), data, client_session, container_sinks) + .await?; + + // Send message to container + let send_result = sink.send(TungsteniteMessage::Text(converted_text)).await; + + // Re-insert sink back into the map if successful, otherwise remove it + match send_result { + Ok(_) => { + let mut sinks = container_sinks.lock().await; + sinks.insert(language, sink); + Ok(()) + } + Err(e) => { + // Don't re-insert the sink on error - it's likely broken + Err(e.into()) + } + } +} diff --git a/crates/proxy/src/handlers/mod.rs b/crates/proxy/src/handlers/mod.rs index eb255cdc..c5a13ff7 100644 --- a/crates/proxy/src/handlers/mod.rs +++ b/crates/proxy/src/handlers/mod.rs @@ -6,10 +6,11 @@ mod find_references; mod health; mod list_files; mod lsp; +mod lsp_ws; mod read_source_code; pub(crate) mod container_proxy; pub use self::{ definitions_in_file::*, find_definition::*, find_identifier::*, find_referenced_symbols::*, - find_references::*, health::*, list_files::*, lsp::*, read_source_code::*, + find_references::*, health::*, list_files::*, lsp::*, lsp_ws::*, read_source_code::*, }; diff --git a/crates/proxy/src/lib.rs b/crates/proxy/src/lib.rs index fc0fb80f..0cf3dad5 100644 --- a/crates/proxy/src/lib.rs +++ b/crates/proxy/src/lib.rs @@ -21,7 +21,7 @@ mod handlers; use crate::handlers::{ definitions_in_file, find_definition, find_identifier, find_referenced_symbols, - find_references, health_check, list_files, lsp, read_source_code, + find_references, health_check, list_files, lsp, lsp_ws, read_source_code, }; use common::api_types::{ get_mount_dir, set_global_mount_dir, CodeContext, DefinitionsInFileRequest, ErrorResponse, @@ -287,11 +287,13 @@ pub async fn run_server_with_port_and_host( if let Some(ref middleware) = jwt_middleware { cfg .service(api_scope.wrap(middleware.clone())) - .route("/lsp", post().to(lsp).wrap(middleware.clone())); + .route("/lsp", post().to(lsp).wrap(middleware.clone())) + .route("/lsp/ws", get().to(lsp_ws).wrap(middleware.clone())); } else { cfg .service(api_scope) - .route("/lsp", post().to(lsp)); + .route("/lsp", post().to(lsp)) + .route("/lsp/ws", get().to(lsp_ws)); } }) .service( diff --git a/crates/wrapper/Cargo.toml b/crates/wrapper/Cargo.toml index 3eb9f5c5..f5ba89dc 100644 --- a/crates/wrapper/Cargo.toml +++ b/crates/wrapper/Cargo.toml @@ -15,6 +15,7 @@ path = "src/main.rs" ast-grep.workspace = true common.workspace = true actix-web.workspace = true +actix-ws.workspace = true tokio.workspace = true serde.workspace = true serde_json.workspace = true @@ -30,6 +31,7 @@ strum.workspace = true strum_macros.workspace = true url.workspace = true utoipa.workspace = true +futures = "0.3" # Wrapper-specific notify-debouncer-mini = "0.4" diff --git a/crates/wrapper/src/handlers/lsp_ws.rs b/crates/wrapper/src/handlers/lsp_ws.rs new file mode 100644 index 00000000..3e46c689 --- /dev/null +++ b/crates/wrapper/src/handlers/lsp_ws.rs @@ -0,0 +1,29 @@ +use crate::managers::ws::WsManager; +use actix_web::{web, HttpRequest, HttpResponse}; +use log::{debug, error}; + +/// WebSocket endpoint for bidirectional LSP communication +pub async fn lsp_ws( + req: HttpRequest, + stream: web::Payload, + data: web::Data, +) -> Result { + debug!("WebSocket connection request received"); + + let (response, session, msg_stream) = actix_ws::handle(&req, stream)?; + + // Get the LSP client from the ApiManager + let client = data.api_manager.get_lsp_client(); + + // Create WsManager and handle the connection + let ws_manager = WsManager::new(client); + + // Spawn task to handle the connection using actix_web::rt (supports !Send futures) + actix_web::rt::spawn(async move { + if let Err(e) = ws_manager.handle_connection(session, msg_stream).await { + error!("WebSocket connection error: {}", e); + } + }); + + Ok(response) +} diff --git a/crates/wrapper/src/handlers/mod.rs b/crates/wrapper/src/handlers/mod.rs index 0386267c..af7e5e01 100644 --- a/crates/wrapper/src/handlers/mod.rs +++ b/crates/wrapper/src/handlers/mod.rs @@ -5,5 +5,6 @@ pub mod find_identifier; pub mod find_referenced_symbols; pub mod find_references; pub mod lsp; +pub mod lsp_ws; pub mod utils; diff --git a/crates/wrapper/src/lsp/client.rs b/crates/wrapper/src/lsp/client.rs index 11207212..de957f6a 100644 --- a/crates/wrapper/src/lsp/client.rs +++ b/crates/wrapper/src/lsp/client.rs @@ -132,6 +132,7 @@ pub trait LspClient: Send { let mut process = self.get_process().clone(); let pending_requests = self.get_pending_requests().clone(); let json_rpc = self.get_json_rpc().clone(); + let notification_channel = self.get_unexpected_notifications_tx(); tokio::spawn(async move { loop { @@ -148,23 +149,29 @@ pub trait LspClient: Send { error!("Failed to send response for request {}", id); } } else { - debug!( - "Responding to server message {} - Message: {:?}", - id, message - ); let response = json_rpc.create_success_response(id); let _ = process.send(&response).await; } - } else if let Some(params) = message.params.clone() { - let message_key = ExpectedMessageKey { - method: message.method.clone().unwrap(), - params, - }; - if let Some(sender) = - pending_requests.remove_notification(message_key).await - { - sender.send(message).unwrap(); + } else if let Some(method) = message.method.clone() { + debug!("Received notification"); + let mut handled = false; + if let Some(params) = &message.params { + let message_key = ExpectedMessageKey { + method, + params: params.clone(), + }; + if let Some(sender) = + pending_requests.remove_notification(message_key).await + { + handled = true; + sender.send(message.clone()).unwrap(); + } + } + if !handled { + let _ = notification_channel.send(message); } + } else { + debug!("Received unexpected message"); } } } @@ -376,6 +383,16 @@ pub trait LspClient: Send { fn get_workspace_documents(&mut self) -> &mut WorkspaceDocumentsHandler; + fn get_unexpected_notifications_tx( + &self, + ) -> tokio::sync::broadcast::Sender; + + fn subscribe_to_unexpected_notifications( + &self, + ) -> tokio::sync::broadcast::Receiver { + self.get_unexpected_notifications_tx().subscribe() + } + /// Sets up the workspace for the language server. /// /// Some language servers require specific commands to be run before diff --git a/crates/wrapper/src/lsp/languages/generic.rs b/crates/wrapper/src/lsp/languages/generic.rs index 88bdd1bc..8a670022 100644 --- a/crates/wrapper/src/lsp/languages/generic.rs +++ b/crates/wrapper/src/lsp/languages/generic.rs @@ -3,6 +3,7 @@ use std::path::Path; use crate::lsp::{JsonRpcHandler, LspClient, PendingRequests, ProcessHandler}; use async_trait::async_trait; +use common::api_types::JsonRpcMessage; use common::utils::workspace_documents::{ DidOpenConfiguration, WorkspaceDocumentsHandler, DEFAULT_EXCLUDE_PATTERNS, }; @@ -14,6 +15,7 @@ pub struct GenericLspClient { json_rpc: JsonRpcHandler, workspace_documents: WorkspaceDocumentsHandler, pending_requests: PendingRequests, + unexpected_notifications_tx: tokio::sync::broadcast::Sender, initialization_options: Option, setup_workspace_method: Option, } @@ -40,8 +42,11 @@ impl LspClient for GenericLspClient { &mut self.pending_requests } - #[allow(deprecated)] + fn get_unexpected_notifications_tx(&self) -> tokio::sync::broadcast::Sender { + self.unexpected_notifications_tx.clone() + } + #[allow(deprecated)] async fn get_initialize_params( &mut self, root_path: String, @@ -76,8 +81,7 @@ impl GenericLspClient { file_patterns: Vec, did_open_config: DidOpenConfiguration, ) -> Self { - let (_tx, rx) = tokio::sync::broadcast::channel(1); - + let (_, workspace_docs_rx) = tokio::sync::broadcast::channel(1); let workspace_documents = WorkspaceDocumentsHandler::new( Path::new(&root_path), file_patterns, @@ -85,17 +89,20 @@ impl GenericLspClient { .iter() .map(|&s| s.to_string()) .collect(), - rx, + workspace_docs_rx, did_open_config, ); let json_rpc_handler = JsonRpcHandler::new(); + let (unexpected_notifications_tx, _) = tokio::sync::broadcast::channel(1); + Self { process, json_rpc: json_rpc_handler, workspace_documents, pending_requests: PendingRequests::new(), + unexpected_notifications_tx, initialization_options: None, setup_workspace_method: None, } diff --git a/crates/wrapper/src/lsp/languages/golang.rs b/crates/wrapper/src/lsp/languages/golang.rs index 84ed2458..83cc0367 100644 --- a/crates/wrapper/src/lsp/languages/golang.rs +++ b/crates/wrapper/src/lsp/languages/golang.rs @@ -3,6 +3,7 @@ use std::path::{Path, PathBuf}; use crate::lsp::{JsonRpcHandler, LspClient, PendingRequests, ProcessHandler}; use async_trait::async_trait; +use common::api_types::JsonRpcMessage; use log::{info, warn}; use lsp_types::{InitializeParams, Url, WorkspaceFolder}; use std::error::Error; @@ -12,6 +13,7 @@ pub struct GoplsClient { json_rpc: JsonRpcHandler, workspace_documents: common::utils::workspace_documents::WorkspaceDocumentsHandler, pending_requests: PendingRequests, + unexpected_notifications_tx: tokio::sync::broadcast::Sender, } #[async_trait] @@ -38,6 +40,10 @@ impl LspClient for GoplsClient { &mut self.pending_requests } + fn get_unexpected_notifications_tx(&self) -> tokio::sync::broadcast::Sender { + self.unexpected_notifications_tx.clone() + } + #[allow(deprecated)] async fn get_initialize_params( @@ -123,11 +129,13 @@ impl GoplsClient { workspace_documents: common::utils::workspace_documents::WorkspaceDocumentsHandler, pending_requests: PendingRequests, ) -> Self { + let (unexpected_notifications_tx, _) = tokio::sync::broadcast::channel(1); Self { process, json_rpc, workspace_documents, pending_requests, + unexpected_notifications_tx, } } } diff --git a/crates/wrapper/src/lsp/languages/sorbet.rs b/crates/wrapper/src/lsp/languages/sorbet.rs index 79ec084b..7635c1ca 100644 --- a/crates/wrapper/src/lsp/languages/sorbet.rs +++ b/crates/wrapper/src/lsp/languages/sorbet.rs @@ -4,6 +4,7 @@ use std::path::PathBuf; use crate::lsp::{JsonRpcHandler, LspClient, PendingRequests, ProcessHandler}; use async_trait::async_trait; +use common::api_types::JsonRpcMessage; use log::{info, warn}; use lsp_types::{InitializeParams, Url, WorkspaceFolder}; use std::error::Error; @@ -16,6 +17,7 @@ pub struct SorbetClient { json_rpc: JsonRpcHandler, workspace_documents: common::utils::workspace_documents::WorkspaceDocumentsHandler, pending_requests: PendingRequests, + unexpected_notifications_tx: tokio::sync::broadcast::Sender, } #[async_trait] @@ -42,8 +44,11 @@ impl LspClient for SorbetClient { &mut self.pending_requests } - #[allow(deprecated)] + fn get_unexpected_notifications_tx(&self) -> tokio::sync::broadcast::Sender { + self.unexpected_notifications_tx.clone() + } + #[allow(deprecated)] async fn get_initialize_params( &mut self, root_path: String, @@ -166,11 +171,13 @@ impl SorbetClient { workspace_documents: common::utils::workspace_documents::WorkspaceDocumentsHandler, pending_requests: PendingRequests, ) -> Self { + let (unexpected_notifications_tx, _) = tokio::sync::broadcast::channel(1); Self { process, json_rpc, workspace_documents, pending_requests, + unexpected_notifications_tx, } } } diff --git a/crates/wrapper/src/main.rs b/crates/wrapper/src/main.rs index 8d673d5e..5fd6aeb6 100644 --- a/crates/wrapper/src/main.rs +++ b/crates/wrapper/src/main.rs @@ -302,6 +302,7 @@ async fn main() -> std::io::Result<()> { App::new() .app_data(app_state.clone()) .route("/lsp", web::post().to(handlers::lsp::lsp)) + .route("/lsp/ws", web::get().to(handlers::lsp_ws::lsp_ws)) .route( "/symbol/definitions-in-file", web::get().to(handlers::definitions_in_file::definitions_in_file), diff --git a/crates/wrapper/src/managers/api.rs b/crates/wrapper/src/managers/api.rs index 5d0d28e6..9754fabd 100644 --- a/crates/wrapper/src/managers/api.rs +++ b/crates/wrapper/src/managers/api.rs @@ -286,6 +286,11 @@ impl ApiManager { Some(()) } + /// Get the underlying LSP client for WebSocket connections + pub fn get_lsp_client(&self) -> Arc>> { + self.client.clone() + } + /// Forward a raw LSP JSON-RPC request to the LSP server /// /// This provides lightweight pass-through of JSON-RPC requests with minimal processing. diff --git a/crates/wrapper/src/managers/mod.rs b/crates/wrapper/src/managers/mod.rs index e5fdf85e..b3bd2fc0 100644 --- a/crates/wrapper/src/managers/mod.rs +++ b/crates/wrapper/src/managers/mod.rs @@ -1 +1,2 @@ pub mod api; +pub mod ws; diff --git a/crates/wrapper/src/managers/ws.rs b/crates/wrapper/src/managers/ws.rs new file mode 100644 index 00000000..993a36d3 --- /dev/null +++ b/crates/wrapper/src/managers/ws.rs @@ -0,0 +1,173 @@ +use crate::lsp::client::LspClient; +use actix_ws::{Message as WsMessage, MessageStream, Session}; +use common::api_types::{JsonRpcErrorCode, JsonRpcMessage}; +use futures::StreamExt; +use log::{debug, error, warn}; +use std::sync::Arc; +use tokio::sync::Mutex; + +/// Manages WebSocket connections for bidirectional LSP communication +pub struct WsManager { + client: Arc>>, +} + +impl WsManager { + pub fn new(client: Arc>>) -> Self { + Self { client } + } + + /// Handle a WebSocket connection, forwarding messages between WebSocket and LSP + pub async fn handle_connection( + &self, + mut session: Session, + mut msg_stream: MessageStream, + ) -> Result<(), Box> { + debug!("New WebSocket connection established"); + + spawn_notification_forwarder(self.client.clone(), session.clone()); + + while let Some(Ok(msg)) = msg_stream.next().await { + match msg { + WsMessage::Text(text) => { + if let Err(e) = + handle_ws_text_message(text.to_string(), &self.client, &session).await + { + error!("Error handling text message: {}", e); + break; + } + } + WsMessage::Ping(bytes) => { + debug!("Received ping"); + if let Err(e) = session.pong(&bytes).await { + error!("Failed to send pong: {}", e); + break; + } + } + WsMessage::Pong(_) => { + debug!("Received pong"); + } + WsMessage::Close(reason) => { + debug!("WebSocket closed: {:?}", reason); + break; + } + _ => { + warn!("Received unsupported WebSocket message type"); + } + } + } + + debug!("WebSocket connection ended"); + Ok(()) + } +} + +/// Spawn a task to forward LSP notifications to the WebSocket +fn spawn_notification_forwarder(client: Arc>>, mut session: Session) { + tokio::spawn(async move { + let mut notification_rx = { + let locked_client = client.lock().await; + locked_client.subscribe_to_unexpected_notifications() + }; + + while let Ok(notification) = notification_rx.recv().await { + debug!( + "Forwarding notification to WebSocket: {}", + notification + .method + .as_ref() + .unwrap_or(&"unknown".to_string()) + ); + + match serde_json::to_string(¬ification) { + Ok(json) => { + if let Err(e) = session.text(json).await { + error!("Failed to send notification to WebSocket: {}", e); + break; + } + } + Err(e) => { + error!("Failed to serialize notification: {}", e); + } + } + } + debug!("Notification forwarding task ended"); + }); +} + +/// Handle a text message from the WebSocket +async fn handle_ws_text_message( + text: String, + client: &Arc>>, + session: &Session, +) -> Result<(), Box> { + debug!("Received WebSocket message: {}", text); + + let json_rpc_msg: JsonRpcMessage = serde_json::from_str(&text)?; + + let method = json_rpc_msg + .method + .clone() + .ok_or("Received message without method field")?; + + if json_rpc_msg.id.is_some() { + handle_lsp_request(json_rpc_msg, method, client.clone(), session.clone()); + } else { + handle_lsp_notification(method, json_rpc_msg.params, client).await?; + } + + Ok(()) +} + +/// Spawn a task to handle an LSP request and send the response +fn handle_lsp_request( + json_rpc_msg: JsonRpcMessage, + method: String, + client: Arc>>, + mut session: Session, +) { + tokio::spawn(async move { + let request_id = json_rpc_msg.id.clone(); + + // Send request to LSP server and wait for response + let response = { + let mut locked_client = client.lock().await; + match locked_client + .send_request(&method, json_rpc_msg.params) + .await + { + Ok(result) => JsonRpcMessage::new_result_response(request_id, result), + Err(e) => { + error!("LSP request failed: {}", e); + JsonRpcMessage::new_error_response( + request_id, + JsonRpcErrorCode::InternalError as i32, + e.to_string(), + ) + } + } + }; + + // Send response back to WebSocket + match serde_json::to_string(&response) { + Ok(json) => { + if let Err(e) = session.text(json).await { + error!("Failed to send response to WebSocket: {}", e); + } + } + Err(e) => { + error!("Failed to serialize response: {}", e); + } + } + }); +} + +/// Handle an LSP notification (no response expected) +async fn handle_lsp_notification( + method: String, + params: Option, + client: &Arc>>, +) -> Result<(), Box> { + let mut locked_client = client.lock().await; + locked_client.send_notification(&method, params).await?; + Ok(()) +} From ac67cc948c9492564438a71f50bf941a3180050e Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Tue, 2 Dec 2025 11:48:14 +0100 Subject: [PATCH 42/92] Add WebSocket endpoint test --- scripts/test-all-endpoints.sh | 87 +++++++++++++++++++++++++++++------ 1 file changed, 72 insertions(+), 15 deletions(-) diff --git a/scripts/test-all-endpoints.sh b/scripts/test-all-endpoints.sh index ed2a5a48..2b5b6533 100755 --- a/scripts/test-all-endpoints.sh +++ b/scripts/test-all-endpoints.sh @@ -172,8 +172,7 @@ php|AStar.php|26|20|1|addNeighborsToOpenList # clangd|astar_search.cpp|??|??|1|?? #" -# Test function -test_endpoint() { +test_http_endpoint() { local test_name="$1" local method="$2" local endpoint="$3" @@ -241,7 +240,59 @@ test_endpoint() { fi } -# Enhanced test function for find-referenced-symbols with deep validation +test_ws_endpoint() { + local test_name="$1" + local endpoint="$2" + local data="$3" + local validation_check="$4" + + TOTAL_TESTS=$((TOTAL_TESTS + 1)) + + echo -n " Testing $test_name... " + + # Build curl command with timeout + local curl_cmd="websocat -q1" + curl_cmd="$curl_cmd 'ws${BASE_URL#http}$endpoint'" + + # Execute request (curl has built-in timeout via --max-time) + if response=$(timeout 30 $curl_cmd 2>&1); then + local body="$response" + + # Validate JSON structure + if ! echo "$body" | jq . > /dev/null 2>&1; then + echo -e "${RED}✗ FAIL${NC} - Invalid JSON response" + echo " Response: $body" | head -3 + FAILED_TESTS=$((FAILED_TESTS + 1)) + return 1 + fi + + # Run custom validation if provided + if [ -n "$validation_check" ]; then + if ! echo "$body" | eval "$validation_check"; then + echo -e "${RED}✗ FAIL${NC} - Validation check failed" + echo " Check: $validation_check" + echo " Response: $body" | head -5 + FAILED_TESTS=$((FAILED_TESTS + 1)) + return 1 + fi + fi + + echo -e "${GREEN}✓ PASS${NC}" + PASSED_TESTS=$((PASSED_TESTS + 1)) + return 0 + else + local exit_code=$? + if [ $exit_code -eq 28 ]; then + echo -e "${RED}✗ FAIL${NC} - Timeout (30s)" + else + echo -e "${RED}✗ FAIL${NC} - Request failed (exit code: $exit_code)" + echo " Error: $response" | head -3 + fi + FAILED_TESTS=$((FAILED_TESTS + 1)) + return 1 + fi +} + test_find_referenced_symbols_enhanced() { local lang="$1" local file="$2" @@ -419,7 +470,7 @@ echo # Test 1: System Health echo -e "${YELLOW}1. System Health Check${NC}" -test_endpoint "Health Check" \ +test_http_endpoint "Health Check" \ "GET" \ "/v1/system/health" \ "" \ @@ -429,7 +480,7 @@ echo # Test 2: List Files (language-agnostic) echo -e "${YELLOW}2. Workspace Endpoints (Language-Agnostic)${NC}" -test_endpoint "List Files" \ +test_http_endpoint "List Files" \ "GET" \ "/v1/workspace/list-files" \ "" \ @@ -450,10 +501,10 @@ while IFS='|' read -r lang test_file symbol_name symbol_line symbol_char health_ test_uri="$WORKSPACE_URI/$test_file" - echo -e "${BLUE}Testing language: $(echo $lang | tr '[:lower:]' '[:upper:]')${NC}" + echo -e "${BLUE}Testing language: $(echo "$lang" | tr '[:lower:]' '[:upper:]')${NC}" # Health check for this language - test_endpoint "Health ($lang)" \ + test_http_endpoint "Health ($lang)" \ "GET" \ "/v1/system/health" \ "" \ @@ -461,7 +512,7 @@ while IFS='|' read -r lang test_file symbol_name symbol_line symbol_char health_ "jq -e '.languages.$health_key == true' > /dev/null" # Read Source Code - test_endpoint "Read Source ($lang)" \ + test_http_endpoint "Read Source ($lang)" \ "POST" \ "/v1/workspace/read-source-code" \ "{\"path\":\"$test_file\"}" \ @@ -469,7 +520,7 @@ while IFS='|' read -r lang test_file symbol_name symbol_line symbol_char health_ "jq -e '.source_code | type == \"string\" and length > 0' > /dev/null" # Read Source Code with Range - test_endpoint "Read Source with Range ($lang)" \ + test_http_endpoint "Read Source with Range ($lang)" \ "POST" \ "/v1/workspace/read-source-code" \ "{\"path\":\"$test_file\",\"range\":{\"start\":{\"line\":0,\"character\":0},\"end\":{\"line\":1,\"character\":0}}}" \ @@ -477,7 +528,7 @@ while IFS='|' read -r lang test_file symbol_name symbol_line symbol_char health_ "jq -e '.source_code | type == \"string\"' > /dev/null" # Find Definition (assert selected identifier and at least one definition) - test_endpoint "Find Definition ($lang)" \ + test_http_endpoint "Find Definition ($lang)" \ "POST" \ "/v1/symbol/find-definition" \ "{\"position\":{\"path\":\"$test_file\",\"position\":{\"line\":$symbol_line,\"character\":$symbol_char}},\"include_source_code\":false,\"include_raw_response\":false}" \ @@ -485,7 +536,7 @@ while IFS='|' read -r lang test_file symbol_name symbol_line symbol_char health_ "jq -e '.selected_identifier.name == \"$symbol_name\" and (.definitions | length) >= 0 and (.selected_identifier.file_range.path == \"$test_file\")' > /dev/null" # Find References (assert selected identifier matches and references is an array) - test_endpoint "Find References ($lang)" \ + test_http_endpoint "Find References ($lang)" \ "POST" \ "/v1/symbol/find-references" \ "{\"identifier_position\":{\"path\":\"$test_file\",\"position\":{\"line\":$symbol_line,\"character\":$symbol_char}},\"include_code_context_lines\":0}" \ @@ -493,7 +544,7 @@ while IFS='|' read -r lang test_file symbol_name symbol_line symbol_char health_ "jq -e '.selected_identifier.name == \"$symbol_name\" and .selected_identifier.file_range.path == \"$test_file\" and (.references | type == \"array\")' > /dev/null" # Find Referenced Symbols - test_endpoint "Find Referenced Symbols ($lang)" \ + test_http_endpoint "Find Referenced Symbols ($lang)" \ "POST" \ "/v1/symbol/find-referenced-symbols" \ "{\"identifier_position\":{\"path\":\"$test_file\",\"position\":{\"line\":$symbol_line,\"character\":$symbol_char}},\"full_scan\":false}" \ @@ -501,7 +552,7 @@ while IFS='|' read -r lang test_file symbol_name symbol_line symbol_char health_ "jq -e 'type == \"object\"' > /dev/null" # Definitions in File - test_endpoint "Definitions in File ($lang)" \ + test_http_endpoint "Definitions in File ($lang)" \ "GET" \ "/v1/symbol/definitions-in-file?file_path=$test_file" \ "" \ @@ -509,7 +560,7 @@ while IFS='|' read -r lang test_file symbol_name symbol_line symbol_char health_ "jq -e 'type == \"array\"' > /dev/null" # Find Identifier - test_endpoint "Find Identifier ($lang)" \ + test_http_endpoint "Find Identifier ($lang)" \ "POST" \ "/v1/symbol/find-identifier" \ "{\"path\":\"$test_file\",\"name\":\"$symbol_name\"}" \ @@ -517,13 +568,19 @@ while IFS='|' read -r lang test_file symbol_name symbol_line symbol_char health_ "jq -e 'type == \"object\"' > /dev/null" # Find Definition (assert selected identifier and at least one definition) - test_endpoint "LSP GoTo Definition ($lang)" \ + test_http_endpoint "LSP GoTo Definition ($lang)" \ "POST" \ "/lsp" \ "{\"jsonrpc\":\"2.0\",\"id\":\"$TOTAL_TESTS\",\"method\":\"textDocument/definition\",\"params\":{\"textDocument\":{\"uri\":\"$test_uri\"},\"position\":{\"line\":$symbol_line,\"character\":$symbol_char}}}" \ "200" \ "jq -e '.result | if type == \"array\" then . else [.] end | length > 0' > /dev/null" + # Find Definition (assert selected identifier and at least one definition) + test_ws_endpoint "LSP-WS GoTo Definition ($lang)" \ + "/lsp/ws" \ + "{\"jsonrpc\":\"2.0\",\"id\":\"$TOTAL_TESTS\",\"method\":\"textDocument/definition\",\"params\":{\"textDocument\":{\"uri\":\"$test_uri\"},\"position\":{\"line\":$symbol_line,\"character\":$symbol_char}}}" \ + "jq -e '.result | if type == \"array\" then . else [.] end | length > 0' > /dev/null" + echo done <<< "$LANGUAGE_CONFIGS" From f4724cae035f28facccc6edc5d2435fd415713bf Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Tue, 2 Dec 2025 15:56:22 +0100 Subject: [PATCH 43/92] Fix logging --- clients/typescript/src/proxy.ts | 2 +- crates/proxy/tests/container_orchestration_test.rs | 2 +- scripts/start-proxy.sh | 2 +- scripts/test-container-lifecycle.sh | 2 +- scripts/test-watchdog.sh | 8 ++++---- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/clients/typescript/src/proxy.ts b/clients/typescript/src/proxy.ts index 7bd1f891..93f735b7 100644 --- a/clients/typescript/src/proxy.ts +++ b/clients/typescript/src/proxy.ts @@ -254,7 +254,7 @@ export async function up( "-e", "USE_AUTH=false", "-e", - `RUST_LOG=info${debug ? ",nuanced-lsp-proxy=debug,nuanced-lsp-wrapper=debug" : ""}`, + `RUST_LOG=info${debug ? ",nuanced_lsp_proxy=debug,proxy=debug,nuanced_lsp_wrapper=debug,wrapper=debug" : ""}`, "-e", `WATCHDOG_IMAGE=${watchdogImage}`, "-e", diff --git a/crates/proxy/tests/container_orchestration_test.rs b/crates/proxy/tests/container_orchestration_test.rs index f1f69313..8bd8c78b 100644 --- a/crates/proxy/tests/container_orchestration_test.rs +++ b/crates/proxy/tests/container_orchestration_test.rs @@ -233,7 +233,7 @@ impl ContainerFixture { let image_version_env = format!("RUST_IMAGE_VERSION={TEST_RUST_IMAGE_VERSION}"); let config: Config<&str> = Config { image: Some(&proxy_img), - env: Some(vec!["USE_AUTH=false", "RUST_LOG=info", &image_version_env]), + env: Some(vec!["USE_AUTH=false", "RUST_LOG=info,nuanced_lsp_proxy=debug,proxy=debug,nuanced_lsp_wrapper=debug,wrapper=debug", &image_version_env]), host_config: Some(bollard::models::HostConfig { binds: Some(vec![ "/var/run/docker.sock:/var/run/docker.sock".to_string(), diff --git a/scripts/start-proxy.sh b/scripts/start-proxy.sh index 04fcb0eb..fef3d431 100755 --- a/scripts/start-proxy.sh +++ b/scripts/start-proxy.sh @@ -115,7 +115,7 @@ DOCKER_RUN_CMD="$DOCKER_RUN_CMD \ -p ${PORT}:4444 \ -v /var/run/docker.sock:/var/run/docker.sock \ -v \"${WORKSPACE_PATH}:/mnt/workspace\" \ - -e RUST_LOG=info \ + -e RUST_LOG=info,nuanced_lsp_proxy=debug,proxy=debug,nuanced_lsp_wrapper=debug,wrapper=debug \ -e WRAPPER_IMAGE=nuanced-lsp-wrapper:${RUST_VERSION} \ -e WATCHDOG_IMAGE=nuanced-lsp-watchdog:${RUST_VERSION} \ -e NUANCED_LSP_MAX_MEMORY=8192" diff --git a/scripts/test-container-lifecycle.sh b/scripts/test-container-lifecycle.sh index 6563ccb1..9ed4a8af 100755 --- a/scripts/test-container-lifecycle.sh +++ b/scripts/test-container-lifecycle.sh @@ -90,7 +90,7 @@ docker run -d \ -p 4444:4444 \ -v /var/run/docker.sock:/var/run/docker.sock \ -v "$WORKSPACE_PATH:/mnt/workspace" \ - -e RUST_LOG=info \ + -e RUST_LOG=info,nuanced_lsp_proxy=debug,proxy=debug,nuanced_lsp_wrapper=debug,wrapper=debug \ -e USE_AUTH=false \ -e WRAPPER_IMAGE=nuanced-lsp-wrapper:${RUST_VERSION} \ -e WATCHDOG_IMAGE=nuanced-lsp-watchdog:${RUST_VERSION} \ diff --git a/scripts/test-watchdog.sh b/scripts/test-watchdog.sh index 5c5e0d2a..38cdf886 100755 --- a/scripts/test-watchdog.sh +++ b/scripts/test-watchdog.sh @@ -192,7 +192,7 @@ docker run -d \ -p 4455:4444 \ -v /var/run/docker.sock:/var/run/docker.sock \ -v "$WORKSPACE_PATH:/mnt/workspace" \ - -e RUST_LOG=info \ + -e RUST_LOG=info,nuanced_lsp_proxy=debug,proxy=debug,nuanced_lsp_wrapper=debug,wrapper=debug \ -e USE_AUTH=false \ -e WRAPPER_IMAGE=nuanced-lsp-wrapper:${RUST_VERSION} \ -e WATCHDOG_IMAGE=nuanced-lsp-watchdog:${RUST_VERSION} \ @@ -262,7 +262,7 @@ docker run -d \ -p 4456:4444 \ -v /var/run/docker.sock:/var/run/docker.sock \ -v "$WORKSPACE_PATH:/mnt/workspace" \ - -e RUST_LOG=info \ + -e RUST_LOG=info,nuanced_lsp_proxy=debug,proxy=debug,nuanced_lsp_wrapper=debug,wrapper=debug \ -e USE_AUTH=false \ -e WRAPPER_IMAGE=nuanced-lsp-wrapper:${RUST_VERSION} \ -e WATCHDOG_IMAGE=nuanced-lsp-watchdog:${RUST_VERSION} \ @@ -313,7 +313,7 @@ docker run -d \ -p 4457:4444 \ -v /var/run/docker.sock:/var/run/docker.sock \ -v "$WORKSPACE_PATH:/mnt/workspace" \ - -e RUST_LOG=warn \ + -e RUST_LOG=info,nuanced_lsp_proxy=debug,proxy=debug,nuanced_lsp_wrapper=debug,wrapper=debug \ -e USE_AUTH=false \ -e WRAPPER_IMAGE=nuanced-lsp-wrapper:${RUST_VERSION} \ -e WATCHDOG_IMAGE=nuanced-lsp-watchdog:${RUST_VERSION} \ @@ -324,7 +324,7 @@ docker run -d \ -p 4458:4444 \ -v /var/run/docker.sock:/var/run/docker.sock \ -v "$WORKSPACE_PATH:/mnt/workspace" \ - -e RUST_LOG=warn \ + -e RUST_LOG=info,nuanced_lsp_proxy=debug,proxy=debug,nuanced_lsp_wrapper=debug,wrapper=debug \ -e USE_AUTH=false \ -e WRAPPER_IMAGE=nuanced-lsp-wrapper:${RUST_VERSION} \ -e WATCHDOG_IMAGE=nuanced-lsp-watchdog:${RUST_VERSION} \ From 8f3015025dca8abd42cd28a09108d45b8dc88d4e Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Tue, 2 Dec 2025 16:49:40 +0100 Subject: [PATCH 44/92] Fix test script --- scripts/test-all-endpoints.sh | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/scripts/test-all-endpoints.sh b/scripts/test-all-endpoints.sh index 2b5b6533..00fe44ba 100755 --- a/scripts/test-all-endpoints.sh +++ b/scripts/test-all-endpoints.sh @@ -106,7 +106,6 @@ cleanup() { echo -e "${GREEN}✓ Orphaned containers cleaned${NC}" fi elif [ "$CLEANUP_ON_EXIT" = false ]; then -u echo echo -e "${YELLOW}Skipping cleanup (--no-cleanup specified)${NC}" echo -e "${YELLOW}To clean up manually, run: ./scripts/stop-proxy.sh --force${NC}" elif [ "$STARTED_SERVICE" = false ]; then @@ -185,14 +184,14 @@ test_http_endpoint() { echo -n " Testing $test_name... " # Build curl command with timeout - local curl_cmd="curl -s -w '\n%{http_code}' --max-time 30 -X $method" + local curl_cmd=("curl" "-s" "-w" '\n%{http_code}' "--max-time" "30" "-X" "$method") if [ -n "$data" ]; then - curl_cmd="$curl_cmd -H 'Content-Type: application/json' -d '$data'" + curl_cmd+=("-H" "Content-Type: application/json" "-d" "$data") fi - curl_cmd="$curl_cmd '$BASE_URL$endpoint'" + curl_cmd+=("$BASE_URL$endpoint") # Execute request (curl has built-in timeout via --max-time) - if response=$(eval "$curl_cmd" 2>&1); then + if response=$("${curl_cmd[@]}" 2>&1); then # Split response body and status code local body=$(echo "$response" | sed '$d') local status=$(echo "$response" | tail -n 1) @@ -251,11 +250,10 @@ test_ws_endpoint() { echo -n " Testing $test_name... " # Build curl command with timeout - local curl_cmd="websocat -q1" - curl_cmd="$curl_cmd 'ws${BASE_URL#http}$endpoint'" + local curl_cmd=("websocat" "-q1" "ws${BASE_URL#http}$endpoint") # Execute request (curl has built-in timeout via --max-time) - if response=$(timeout 30 $curl_cmd 2>&1); then + if response=$(echo "$data" | timeout 30 "${curl_cmd[@]}" 2>&1); then local body="$response" # Validate JSON structure From 457505ec4c23302ec891dc2bdda7770094f3bf83 Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Tue, 2 Dec 2025 18:08:00 +0100 Subject: [PATCH 45/92] Check for required tools --- scripts/code-with-lsp | 8 ++++++++ scripts/test-all-endpoints.sh | 12 ++++++++++++ scripts/test-container-lifecycle.sh | 15 +++++++++++++-- scripts/test-watchdog.sh | 14 +++++++++++++- 4 files changed, 46 insertions(+), 3 deletions(-) create mode 100755 scripts/code-with-lsp diff --git a/scripts/code-with-lsp b/scripts/code-with-lsp new file mode 100755 index 00000000..7eb00031 --- /dev/null +++ b/scripts/code-with-lsp @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +set -eu + +script_dir="$(dirname "$0")" +vscode_dir="$(realpath "$script_dir/../clients/vscode")" + +exec code --extensionDevelopmentPath="$vscode_dir" "$@" diff --git a/scripts/test-all-endpoints.sh b/scripts/test-all-endpoints.sh index 00fe44ba..7e820dce 100755 --- a/scripts/test-all-endpoints.sh +++ b/scripts/test-all-endpoints.sh @@ -71,6 +71,18 @@ case ${#positional_args[@]} in ;; esac +# Check required tools are installed +missing_tools=() +for tool in curl docker websocat; do + if ! command -v "$tool" &>/dev/null; then + missing_tools+=("$tool") + fi +done +if [ ${#missing_tools[@]} -gt 0 ]; then + echo -e "${RED}Error: The following required tools are not installed: ${missing_tools[*]}${NC}" >&2 + exit 1 +fi + # Counters TOTAL_TESTS=0 PASSED_TESTS=0 diff --git a/scripts/test-container-lifecycle.sh b/scripts/test-container-lifecycle.sh index 9ed4a8af..67609f18 100755 --- a/scripts/test-container-lifecycle.sh +++ b/scripts/test-container-lifecycle.sh @@ -12,8 +12,19 @@ BLUE='\033[0;34m' YELLOW='\033[1;33m' NC='\033[0m' # No Color -WORKSPACE_PATH="${1:-sample_project/python}" -WORKSPACE_PATH="$(cd "$WORKSPACE_PATH" && pwd)" +# Check required tools are installed +missing_tools=() +for tool in curl docker; do + if ! command -v "$tool" &>/dev/null; then + missing_tools+=("$tool") + fi +done +if [ ${#missing_tools[@]} -gt 0 ]; then + echo -e "${RED}Error: The following required tools are not installed: ${missing_tools[*]}${NC}" >&2 + exit 1 +fi + +WORKSPACE_PATH="$(realpath "${1:-sample_project/python}")" SERVICE_NAME="nuanced-lsp-proxy-$(uuidgen | tr 'A-Z' 'a-z' | cut -c1-12)" # Flag to track if we started containers diff --git a/scripts/test-watchdog.sh b/scripts/test-watchdog.sh index 38cdf886..a19523ed 100755 --- a/scripts/test-watchdog.sh +++ b/scripts/test-watchdog.sh @@ -12,7 +12,19 @@ BLUE='\033[0;34m' YELLOW='\033[1;33m' NC='\033[0m' # No Color -WORKSPACE_PATH="${1:-sample_project/python}" +# Check required tools are installed +missing_tools=() +for tool in curl docker; do + if ! command -v "$tool" &>/dev/null; then + missing_tools+=("$tool") + fi +done +if [ ${#missing_tools[@]} -gt 0 ]; then + echo -e "${RED}Error: The following required tools are not installed: ${missing_tools[*]}${NC}" >&2 + exit 1 +fi + +WORKSPACE_PATH="$(realpath "${1:-sample_project/python}")" WORKSPACE_PATH="$(cd "$WORKSPACE_PATH" && pwd)" # Use RUST_IMAGE_VERSION from environment, default to "latest" From 7f51e72963e7cb8bc27c32818e4571d7c212ccb6 Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Tue, 2 Dec 2025 18:16:40 +0100 Subject: [PATCH 46/92] Install websocat in CI --- .github/workflows/rust-tests.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/rust-tests.yml b/.github/workflows/rust-tests.yml index 53d5f488..5eac4a17 100644 --- a/.github/workflows/rust-tests.yml +++ b/.github/workflows/rust-tests.yml @@ -109,6 +109,14 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Install websocat + uses: jaxxstorm/action-install-gh-release@v1.10.0 + with: + repo: vi/websocat + tag: v1.14.0 + rename-to: websocat + chmod: 0755 + - uses: actions/download-artifact@v4 with: name: built-images From f5de3439daec7f0e357f0882ff076f9bdc480be3 Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Tue, 2 Dec 2025 18:32:55 +0100 Subject: [PATCH 47/92] Add VSCode extension --- clients/vscode/README.md | 40 +++++++++ clients/vscode/dist/extension.d.ts | 4 + clients/vscode/dist/extension.d.ts.map | 1 + clients/vscode/dist/extension.js | 116 +++++++++++++++++++++++++ clients/vscode/dist/extension.js.map | 1 + clients/vscode/eslint.config.cjs | 73 ++++++++++++++++ clients/vscode/package.json | 90 +++++++++++++++++++ clients/vscode/src/extension.ts | 105 ++++++++++++++++++++++ clients/vscode/tsconfig.json | 25 ++++++ 9 files changed, 455 insertions(+) create mode 100644 clients/vscode/README.md create mode 100644 clients/vscode/dist/extension.d.ts create mode 100644 clients/vscode/dist/extension.d.ts.map create mode 100644 clients/vscode/dist/extension.js create mode 100644 clients/vscode/dist/extension.js.map create mode 100644 clients/vscode/eslint.config.cjs create mode 100644 clients/vscode/package.json create mode 100644 clients/vscode/src/extension.ts create mode 100644 clients/vscode/tsconfig.json diff --git a/clients/vscode/README.md b/clients/vscode/README.md new file mode 100644 index 00000000..2027d8b8 --- /dev/null +++ b/clients/vscode/README.md @@ -0,0 +1,40 @@ +# Nuanced LSP VSCode Extension + +VSCode extension for Nuanced LSP server providing advanced language features. + +## Development + +### Building + +```bash +npm install +npm run build +``` + +### Running from Command Line + +To start VSCode with the extension enabled for development: + +```bash +# From the vscode directory +code --extensionDevelopmentPath="$(pwd)" /path/to/your/workspace +``` + +### Packaging + +To create a VSIX package for distribution: + +```bash +npm run package +``` + +This will create a `.vsix` file that can be installed in VSCode via: + +```bash +code --install-extension nuanced-lsp-vscode-0.1.0.vsix +``` + +## Requirements + +- VSCode 1.75.0 or higher +- `nuanced-lsp` command available in your PATH diff --git a/clients/vscode/dist/extension.d.ts b/clients/vscode/dist/extension.d.ts new file mode 100644 index 00000000..30af23db --- /dev/null +++ b/clients/vscode/dist/extension.d.ts @@ -0,0 +1,4 @@ +import * as vscode from "vscode"; +export declare function activate(context: vscode.ExtensionContext): void; +export declare function deactivate(): Thenable | undefined; +//# sourceMappingURL=extension.d.ts.map \ No newline at end of file diff --git a/clients/vscode/dist/extension.d.ts.map b/clients/vscode/dist/extension.d.ts.map new file mode 100644 index 00000000..9287bdcd --- /dev/null +++ b/clients/vscode/dist/extension.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"extension.d.ts","sourceRoot":"","sources":["../src/extension.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,MAAM,QAAQ,CAAC;AAmFjC,wBAAgB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC,gBAAgB,QAcxD;AAED,wBAAgB,UAAU,IAAI,QAAQ,CAAC,IAAI,CAAC,GAAG,SAAS,CAKvD"} \ No newline at end of file diff --git a/clients/vscode/dist/extension.js b/clients/vscode/dist/extension.js new file mode 100644 index 00000000..70f4cfbe --- /dev/null +++ b/clients/vscode/dist/extension.js @@ -0,0 +1,116 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", { value: true }); +exports.activate = activate; +exports.deactivate = deactivate; +const vscode = __importStar(require("vscode")); +const node_1 = require("vscode-languageclient/node"); +let client; +function startClient() { + const config = vscode.workspace.getConfiguration("nuancedLsp"); + const command = config.get("command", "nuanced-lsp"); + const proxyImage = config.get("proxyImage", ""); + const watchdogImage = config.get("watchdogImage", ""); + const wrapperImage = config.get("wrapperImage", ""); + const debug = config.get("debug", false); + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + if (!workspaceFolder) { + vscode.window.showErrorMessage("Nuanced LSP: No workspace folder found. Please open a folder to use this extension."); + return; + } + const workspacePath = workspaceFolder.uri.fsPath; + const args = ["server", "--host-port", "0"]; + if (proxyImage) { + args.push("--proxy-image", proxyImage); + } + if (watchdogImage) { + args.push("--watchdog-image", watchdogImage); + } + if (wrapperImage) { + args.push("--wrapper-image", wrapperImage); + } + if (debug) { + args.push("--debug"); + } + args.push(workspacePath); + const serverOptions = { + command, + args, + }; + const clientOptions = { + documentSelector: [ + { scheme: "file", language: "python" }, + { scheme: "file", language: "typescript" }, + { scheme: "file", language: "javascript" }, + { scheme: "file", language: "rust" }, + { scheme: "file", language: "cpp" }, + { scheme: "file", language: "csharp" }, + { scheme: "file", language: "java" }, + { scheme: "file", language: "go" }, + { scheme: "file", language: "php" }, + { scheme: "file", language: "ruby" }, + ], + synchronize: { + fileEvents: vscode.workspace.createFileSystemWatcher("**/*"), + }, + }; + client = new node_1.LanguageClient("nuancedLsp", "Nuanced LSP", serverOptions, clientOptions); + client.start(); +} +async function restartClient() { + if (client) { + await client.stop(); + } + startClient(); + vscode.window.showInformationMessage("Nuanced LSP: Server restarted"); +} +function activate(context) { + startClient(); + context.subscriptions.push(vscode.commands.registerCommand("nuancedLsp.restart", restartClient)); + context.subscriptions.push({ + dispose: () => { + if (client) { + client.stop(); + } + }, + }); +} +function deactivate() { + if (!client) { + return undefined; + } + return client.stop(); +} +//# sourceMappingURL=extension.js.map \ No newline at end of file diff --git a/clients/vscode/dist/extension.js.map b/clients/vscode/dist/extension.js.map new file mode 100644 index 00000000..fa58cade --- /dev/null +++ b/clients/vscode/dist/extension.js.map @@ -0,0 +1 @@ +{"version":3,"file":"extension.js","sourceRoot":"","sources":["../src/extension.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAmFA,4BAcC;AAED,gCAKC;AAxGD,+CAAiC;AACjC,qDAIoC;AAEpC,IAAI,MAAkC,CAAC;AAEvC,SAAS,WAAW;IAClB,MAAM,MAAM,GAAG,MAAM,CAAC,SAAS,CAAC,gBAAgB,CAAC,YAAY,CAAC,CAAC;IAC/D,MAAM,OAAO,GAAG,MAAM,CAAC,GAAG,CAAS,SAAS,EAAE,aAAa,CAAC,CAAC;IAC7D,MAAM,UAAU,GAAG,MAAM,CAAC,GAAG,CAAS,YAAY,EAAE,EAAE,CAAC,CAAC;IACxD,MAAM,aAAa,GAAG,MAAM,CAAC,GAAG,CAAS,eAAe,EAAE,EAAE,CAAC,CAAC;IAC9D,MAAM,YAAY,GAAG,MAAM,CAAC,GAAG,CAAS,cAAc,EAAE,EAAE,CAAC,CAAC;IAC5D,MAAM,KAAK,GAAG,MAAM,CAAC,GAAG,CAAU,OAAO,EAAE,KAAK,CAAC,CAAC;IAElD,MAAM,eAAe,GAAG,MAAM,CAAC,SAAS,CAAC,gBAAgB,EAAE,CAAC,CAAC,CAAC,CAAC;IAC/D,IAAI,CAAC,eAAe,EAAE,CAAC;QACrB,MAAM,CAAC,MAAM,CAAC,gBAAgB,CAC5B,qFAAqF,CACtF,CAAC;QACF,OAAO;IACT,CAAC;IAED,MAAM,aAAa,GAAG,eAAe,CAAC,GAAG,CAAC,MAAM,CAAC;IAEjD,MAAM,IAAI,GAAG,CAAC,QAAQ,EAAE,aAAa,EAAE,GAAG,CAAC,CAAC;IAC5C,IAAI,UAAU,EAAE,CAAC;QACf,IAAI,CAAC,IAAI,CAAC,eAAe,EAAE,UAAU,CAAC,CAAC;IACzC,CAAC;IACD,IAAI,aAAa,EAAE,CAAC;QAClB,IAAI,CAAC,IAAI,CAAC,kBAAkB,EAAE,aAAa,CAAC,CAAC;IAC/C,CAAC;IACD,IAAI,YAAY,EAAE,CAAC;QACjB,IAAI,CAAC,IAAI,CAAC,iBAAiB,EAAE,YAAY,CAAC,CAAC;IAC7C,CAAC;IACD,IAAI,KAAK,EAAE,CAAC;QACV,IAAI,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC;IACvB,CAAC;IACD,IAAI,CAAC,IAAI,CAAC,aAAa,CAAC,CAAC;IAEzB,MAAM,aAAa,GAAkB;QACnC,OAAO;QACP,IAAI;KACL,CAAC;IAEF,MAAM,aAAa,GAA0B;QAC3C,gBAAgB,EAAE;YAChB,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,QAAQ,EAAE;YACtC,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,YAAY,EAAE;YAC1C,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,YAAY,EAAE;YAC1C,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE;YACpC,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,KAAK,EAAE;YACnC,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,QAAQ,EAAE;YACtC,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE;YACpC,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE;YAClC,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,KAAK,EAAE;YACnC,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE;SACrC;QACD,WAAW,EAAE;YACX,UAAU,EAAE,MAAM,CAAC,SAAS,CAAC,uBAAuB,CAAC,MAAM,CAAC;SAC7D;KACF,CAAC;IAEF,MAAM,GAAG,IAAI,qBAAc,CACzB,YAAY,EACZ,aAAa,EACb,aAAa,EACb,aAAa,CACd,CAAC;IAEF,MAAM,CAAC,KAAK,EAAE,CAAC;AACjB,CAAC;AAED,KAAK,UAAU,aAAa;IAC1B,IAAI,MAAM,EAAE,CAAC;QACX,MAAM,MAAM,CAAC,IAAI,EAAE,CAAC;IACtB,CAAC;IACD,WAAW,EAAE,CAAC;IACd,MAAM,CAAC,MAAM,CAAC,sBAAsB,CAAC,+BAA+B,CAAC,CAAC;AACxE,CAAC;AAED,SAAgB,QAAQ,CAAC,OAAgC;IACvD,WAAW,EAAE,CAAC;IAEd,OAAO,CAAC,aAAa,CAAC,IAAI,CACxB,MAAM,CAAC,QAAQ,CAAC,eAAe,CAAC,oBAAoB,EAAE,aAAa,CAAC,CACrE,CAAC;IAEF,OAAO,CAAC,aAAa,CAAC,IAAI,CAAC;QACzB,OAAO,EAAE,GAAG,EAAE;YACZ,IAAI,MAAM,EAAE,CAAC;gBACX,MAAM,CAAC,IAAI,EAAE,CAAC;YAChB,CAAC;QACH,CAAC;KACF,CAAC,CAAC;AACL,CAAC;AAED,SAAgB,UAAU;IACxB,IAAI,CAAC,MAAM,EAAE,CAAC;QACZ,OAAO,SAAS,CAAC;IACnB,CAAC;IACD,OAAO,MAAM,CAAC,IAAI,EAAE,CAAC;AACvB,CAAC"} \ No newline at end of file diff --git a/clients/vscode/eslint.config.cjs b/clients/vscode/eslint.config.cjs new file mode 100644 index 00000000..2559c33e --- /dev/null +++ b/clients/vscode/eslint.config.cjs @@ -0,0 +1,73 @@ +const js = require("@eslint/js"); +const typescript = require("typescript-eslint"); +const prettierConfigRecommended = require("eslint-plugin-prettier/recommended"); +const importPlugin = require("eslint-plugin-import"); +const stylistic = require("@stylistic/eslint-plugin"); + +module.exports = [ + js.configs.recommended, + ...typescript.configs.recommended, + prettierConfigRecommended, + importPlugin.flatConfigs.recommended, + importPlugin.flatConfigs.typescript, + { + files: ["**/*.ts", "**/*.tsx"], + settings: { + "import/resolver": { + typescript: true, + node: true, + }, + }, + languageOptions: { + parserOptions: { + ecmaVersion: "latest", + sourceType: "commonjs", + tsconfigRootDir: __dirname, + }, + globals: { + clearTimeout: "readonly", + fetch: "readonly", + global: "readonly", + setTimeout: "readonly", + Response: "readonly", + __dirname: "readonly", + __filename: "readonly", + AbortController: "readonly", + Buffer: "readonly", + BufferEncoding: "readonly", + NodeJS: "readonly", + }, + }, + plugins: { + "@stylistic": stylistic, + }, + rules: { + "no-console": "off", + "@typescript-eslint/explicit-function-return-type": "off", + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-unused-vars": [ + "error", + { + argsIgnorePattern: "^_", + varsIgnorePattern: "^_", + destructuredArrayIgnorePattern: "^_", + }, + ], + "prettier/prettier": "error", + "@stylistic/comma-dangle": ["error", "always-multiline"], + "@stylistic/eol-last": ["error", "always"], + "@stylistic/no-multiple-empty-lines": ["error", { max: 1, maxEOF: 0 }], + "@stylistic/no-trailing-spaces": "error", + "@stylistic/quotes": ["error", "double"], + "@stylistic/semi": ["error", "always"], + "import/first": "error", + "import/no-extraneous-dependencies": "error", + "import/no-unassigned-import": "error", + "import/no-unresolved": "error", + "import/order": "error", + }, + }, + { + ignores: ["dist/", "*.config.cjs", "node_modules/"], + }, +]; diff --git a/clients/vscode/package.json b/clients/vscode/package.json new file mode 100644 index 00000000..23c4e5ad --- /dev/null +++ b/clients/vscode/package.json @@ -0,0 +1,90 @@ +{ + "name": "@nuanced-dev/lsp-vscode", + "displayName": "Nuanced LSP", + "description": "VSCode extension for Nuanced LSP server", + "version": "0.1.0", + "publisher": "nuanced-dev", + "engines": { + "vscode": "^1.75.0", + "node": ">=18" + }, + "categories": [ + "Programming Languages" + ], + "main": "./dist/extension.js", + "activationEvents": [ + "onLanguage:python", + "onLanguage:typescript", + "onLanguage:javascript", + "onLanguage:rust", + "onLanguage:cpp", + "onLanguage:csharp", + "onLanguage:java", + "onLanguage:go", + "onLanguage:php", + "onLanguage:ruby" + ], + "contributes": { + "commands": [ + { + "command": "nuancedLsp.restart", + "title": "Nuanced LSP: Restart Server" + } + ], + "configuration": { + "title": "Nuanced LSP", + "properties": { + "nuancedLsp.command": { + "type": "string", + "default": "nuanced-lsp", + "description": "Command to run the Nuanced LSP server" + }, + "nuancedLsp.proxyImage": { + "type": "string", + "default": "", + "description": "The LSP proxy image to use (optional)" + }, + "nuancedLsp.wrapperImage": { + "type": "string", + "default": "", + "description": "The LSP wrapper image to use (optional)" + }, + "nuancedLsp.watchdogImage": { + "type": "string", + "default": "", + "description": "The LSP watchdog image to use (optional)" + }, + "nuancedLsp.debug": { + "type": "boolean", + "default": false, + "description": "Enable debug mode for the Nuanced LSP server" + } + } + } + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "clean": "rm -rf dist nuanced-lsp-vscode-*.vsix", + "lint": "eslint", + "lint:fix": "eslint --fix", + "package": "vsce package", + "vscode:prepublish": "bun run build" + }, + "dependencies": { + "vscode-languageclient": "^9.0.1" + }, + "devDependencies": { + "@eslint/js": "^9.37.0", + "@stylistic/eslint-plugin": "^5.4.0", + "@types/node": "^20.11.30", + "@types/vscode": "^1.75.0", + "@vscode/vsce": "^3.2.1", + "eslint": "^9.33.0", + "eslint-config-prettier": "^10.1.5", + "eslint-import-resolver-typescript": "^4.4.4", + "eslint-plugin-import": "^2.32.0", + "eslint-plugin-prettier": "^5.4.1", + "typescript": "^5.5.4", + "typescript-eslint": "^8.45.0" + } +} diff --git a/clients/vscode/src/extension.ts b/clients/vscode/src/extension.ts new file mode 100644 index 00000000..c07b028f --- /dev/null +++ b/clients/vscode/src/extension.ts @@ -0,0 +1,105 @@ +import * as vscode from "vscode"; +import { + LanguageClient, + LanguageClientOptions, + ServerOptions, +} from "vscode-languageclient/node"; + +let client: LanguageClient | undefined; + +function startClient() { + const config = vscode.workspace.getConfiguration("nuancedLsp"); + const command = config.get("command", "nuanced-lsp"); + const proxyImage = config.get("proxyImage", ""); + const watchdogImage = config.get("watchdogImage", ""); + const wrapperImage = config.get("wrapperImage", ""); + const debug = config.get("debug", false); + + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + if (!workspaceFolder) { + vscode.window.showErrorMessage( + "Nuanced LSP: No workspace folder found. Please open a folder to use this extension.", + ); + return; + } + + const workspacePath = workspaceFolder.uri.fsPath; + + const args = ["server", "--host-port", "0"]; + if (proxyImage) { + args.push("--proxy-image", proxyImage); + } + if (watchdogImage) { + args.push("--watchdog-image", watchdogImage); + } + if (wrapperImage) { + args.push("--wrapper-image", wrapperImage); + } + if (debug) { + args.push("--debug"); + } + args.push(workspacePath); + + const serverOptions: ServerOptions = { + command, + args, + }; + + const clientOptions: LanguageClientOptions = { + documentSelector: [ + { scheme: "file", language: "python" }, + { scheme: "file", language: "typescript" }, + { scheme: "file", language: "javascript" }, + { scheme: "file", language: "rust" }, + { scheme: "file", language: "cpp" }, + { scheme: "file", language: "csharp" }, + { scheme: "file", language: "java" }, + { scheme: "file", language: "go" }, + { scheme: "file", language: "php" }, + { scheme: "file", language: "ruby" }, + ], + synchronize: { + fileEvents: vscode.workspace.createFileSystemWatcher("**/*"), + }, + }; + + client = new LanguageClient( + "nuancedLsp", + "Nuanced LSP", + serverOptions, + clientOptions, + ); + + client.start(); +} + +async function restartClient() { + if (client) { + await client.stop(); + } + startClient(); + vscode.window.showInformationMessage("Nuanced LSP: Server restarted"); +} + +export function activate(context: vscode.ExtensionContext) { + startClient(); + + context.subscriptions.push( + vscode.commands.registerCommand("nuancedLsp.restart", restartClient), + ); + + context.subscriptions.push({ + dispose: () => { + if (client) { + client.stop(); + } + }, + }); +} + +export function deactivate(): Thenable | undefined { + if (!client) { + return undefined; + } + return client.stop(); +} diff --git a/clients/vscode/tsconfig.json b/clients/vscode/tsconfig.json new file mode 100644 index 00000000..ac51ef92 --- /dev/null +++ b/clients/vscode/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "moduleResolution": "node", + "lib": ["ES2020"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "types": ["vscode", "node"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.config.cjs"] +} From 7cc25bbfb09ef88312d6e7c2f5f17c6fc610bf2e Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Tue, 2 Dec 2025 18:33:29 +0100 Subject: [PATCH 48/92] Handle lifecycle messages without response correctly --- crates/proxy/src/handlers/lsp.rs | 19 ++++++++++++------- crates/proxy/src/handlers/lsp_ws.rs | 8 +++++--- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/crates/proxy/src/handlers/lsp.rs b/crates/proxy/src/handlers/lsp.rs index 93cfad32..b6649646 100644 --- a/crates/proxy/src/handlers/lsp.rs +++ b/crates/proxy/src/handlers/lsp.rs @@ -35,8 +35,12 @@ pub async fn lsp(data: Data, request: Json) -> HttpRes debug!("LSP request: {:?}", &lsp_req); // Handle lifecycle requests locally - if let Some(response) = handle_lifecycle_request(&lsp_req) { - return HttpResponse::Ok().json(response); + if let Ok(result) = handle_lifecycle_request(&lsp_req) { + if let Some(response) = result { + return HttpResponse::Ok().json(response); + } else { + return HttpResponse::Ok().finish(); + } } // For language feature requests, extract document URI and route to appropriate backend @@ -150,12 +154,12 @@ pub async fn lsp(data: Data, request: Json) -> HttpRes } /// Handle lifecycle requests locally, returning a JSON-RPC response if handled -pub fn handle_lifecycle_request(request: &JsonRpcMessage) -> Option { +pub fn handle_lifecycle_request(request: &JsonRpcMessage) -> Result, ()> { let req_id = request.id.clone(); let Some(method) = request.method.as_ref() else { - return None; + return Err(()); }; - match method.as_str() { + let response = match method.as_str() { "initialize" => { let mut capabilities = ServerCapabilities::default(); capabilities.call_hierarchy_provider = Some(true.into()); @@ -203,8 +207,9 @@ pub fn handle_lifecycle_request(request: &JsonRpcMessage) -> Option None, - } + _ => return Err(()), + }; + Ok(response) } /// Extract document URI from JSON-RPC request diff --git a/crates/proxy/src/handlers/lsp_ws.rs b/crates/proxy/src/handlers/lsp_ws.rs index f5276716..2fb6e097 100644 --- a/crates/proxy/src/handlers/lsp_ws.rs +++ b/crates/proxy/src/handlers/lsp_ws.rs @@ -107,9 +107,11 @@ async fn handle_client_text_message( let json_rpc_msg: JsonRpcMessage = serde_json::from_str(&text)?; // Handle lifecycle requests locally - if let Some(response) = handle_lifecycle_request(&json_rpc_msg) { - let response_text = serde_json::to_string(&response)?; - client_session.text(response_text).await?; + if let Ok(result) = handle_lifecycle_request(&json_rpc_msg) { + if let Some(response) = result { + let response_text = serde_json::to_string(&response)?; + client_session.text(response_text).await?; + } return Ok(()); } From f0ea3ab6623cdb94ce4e6efcc4f0faa94e012533 Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Wed, 3 Dec 2025 14:41:27 +0100 Subject: [PATCH 49/92] Simplify WS sink cache --- crates/proxy/src/handlers/lsp_ws.rs | 108 ++++++++++++---------------- 1 file changed, 47 insertions(+), 61 deletions(-) diff --git a/crates/proxy/src/handlers/lsp_ws.rs b/crates/proxy/src/handlers/lsp_ws.rs index 2fb6e097..1255acae 100644 --- a/crates/proxy/src/handlers/lsp_ws.rs +++ b/crates/proxy/src/handlers/lsp_ws.rs @@ -12,9 +12,9 @@ use common::utils::language_utils::detect_language; use futures_util::stream::SplitSink; use futures_util::{SinkExt, StreamExt}; use log::{debug, error, info, warn}; +use std::collections::hash_map::Entry; use std::collections::HashMap; use std::sync::Arc; -use tokio::sync::Mutex; use tokio_tungstenite::tungstenite::Message as TungsteniteMessage; use tokio_tungstenite::{MaybeTlsStream, WebSocketStream}; use url::Url; @@ -50,8 +50,7 @@ async fn handle_ws_connection( debug!("Handling WebSocket connection"); // Cache of container WebSocket sinks by language (for sending messages to containers) - let container_sinks: Arc>> = - Arc::new(Mutex::new(HashMap::new())); + let mut container_sinks: HashMap = HashMap::new(); // Process incoming messages from client while let Some(result) = client_stream.next().await { @@ -61,7 +60,7 @@ async fn handle_ws_connection( text.to_string(), &mut client_session, &data, - &container_sinks, + &mut container_sinks, ) .await { @@ -99,7 +98,7 @@ async fn handle_client_text_message( text: String, client_session: &mut Session, data: &Data, - container_sinks: &Arc>>, + container_sinks: &mut HashMap, ) -> Result<(), Box> { debug!("Client -> Proxy: {}", text); @@ -153,47 +152,46 @@ fn uri_to_file_path(uri: &str) -> Result( language: SupportedLanguages, data: &Data, client_session: &Session, - container_sinks: &Arc>>, -) -> Result> { - let mut sinks = container_sinks.lock().await; - - // Return existing sink if available - if sinks.contains_key(&language) { - // We need to remove and return the sink to satisfy borrow checker - // It will be re-inserted by the caller - return Ok(sinks.remove(&language).unwrap()); - } - - // Get container client for this language - let container_client = - container_proxy::get_container_api_client(&data.orchestrator, language.clone()).await?; - - // Connect to container's WebSocket endpoint - let container_ws_url = format!("{}/lsp/ws", container_client.get_base_url()) - .replace("http://", "ws://") - .replace("https://", "wss://"); - - debug!("Connecting to container WebSocket: {}", container_ws_url); - - let (container_ws, _) = tokio_tungstenite::connect_async(&container_ws_url).await?; - - info!("Connected to container WebSocket for {:?}", language); - - // Split WebSocket into read and write halves - let (sink, stream) = container_ws.split(); - - // Spawn a task to read from this connection and forward to client - spawn_container_reader( - language.clone(), - stream, - client_session.clone(), - data.orchestrator.clone(), - ); - + container_sinks: &'a mut HashMap, +) -> Result<&'a mut ContainerWsSink, Box> { + let sink = match container_sinks.entry(language) { + Entry::Occupied(o) => o.into_mut(), + Entry::Vacant(v) => { + let language = v.key(); + + // Get container client for this language + let container_client = + container_proxy::get_container_api_client(&data.orchestrator, language.clone()) + .await?; + + // Connect to container's WebSocket endpoint + let container_ws_url = format!("{}/lsp/ws", container_client.get_base_url()) + .replace("http://", "ws://") + .replace("https://", "wss://"); + + debug!("Connecting to container WebSocket: {}", container_ws_url); + + let (container_ws, _) = tokio_tungstenite::connect_async(&container_ws_url).await?; + + info!("Connected to container WebSocket for {:?}", language); + + // Split WebSocket into read and write halves + let (sink, stream) = container_ws.split(); + + // Spawn a task to read from this connection and forward to client + spawn_container_reader( + language.clone(), + stream, + client_session.clone(), + data.orchestrator.clone(), + ); + v.insert(sink) + } + }; Ok(sink) } @@ -276,7 +274,7 @@ async fn send_to_container( language: SupportedLanguages, data: &Data, client_session: &Session, - container_sinks: &Arc>>, + container_sinks: &mut HashMap, ) -> Result<(), Box> { // Convert paths in message let mut converted_msg = json_rpc_msg; @@ -288,23 +286,11 @@ async fn send_to_container( let converted_text = serde_json::to_string(&converted_msg)?; // Get or create the sink for this language - let mut sink = - ensure_container_connection(language.clone(), data, client_session, container_sinks) - .await?; + let sink = ensure_container_connection(language.clone(), data, client_session, container_sinks) + .await?; // Send message to container - let send_result = sink.send(TungsteniteMessage::Text(converted_text)).await; - - // Re-insert sink back into the map if successful, otherwise remove it - match send_result { - Ok(_) => { - let mut sinks = container_sinks.lock().await; - sinks.insert(language, sink); - Ok(()) - } - Err(e) => { - // Don't re-insert the sink on error - it's likely broken - Err(e.into()) - } - } + sink.send(TungsteniteMessage::Text(converted_text)) + .await + .map_err(|e| e.into()) } From 3d3d9590c41237bf32ea33a3d44c87c2c1115819 Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Wed, 3 Dec 2025 14:44:37 +0100 Subject: [PATCH 50/92] Remove unsafe errors in tests --- crates/proxy/src/container/mod.rs | 44 +++++++++++++++++++++++-------- 1 file changed, 33 insertions(+), 11 deletions(-) diff --git a/crates/proxy/src/container/mod.rs b/crates/proxy/src/container/mod.rs index 0fa332a7..f7f67a87 100644 --- a/crates/proxy/src/container/mod.rs +++ b/crates/proxy/src/container/mod.rs @@ -1030,7 +1030,9 @@ mod tests { #[serial] fn test_get_enabled_languages_not_set() { // Ensure variable is not set - std::env::remove_var("ENABLED_LANGUAGES"); + unsafe { + std::env::remove_var("ENABLED_LANGUAGES"); + } let result = ContainerOrchestrator::get_enabled_languages(); assert!(result.is_none(), "Should return None when variable not set"); @@ -1039,7 +1041,9 @@ mod tests { #[test] #[serial] fn test_get_enabled_languages_single() { - std::env::set_var("ENABLED_LANGUAGES", "python"); + unsafe { + std::env::set_var("ENABLED_LANGUAGES", "python"); + } let result = ContainerOrchestrator::get_enabled_languages(); assert!(result.is_some()); @@ -1048,13 +1052,17 @@ mod tests { assert_eq!(languages.len(), 1); assert!(languages.contains(&SupportedLanguages::Python)); - std::env::remove_var("ENABLED_LANGUAGES"); + unsafe { + std::env::remove_var("ENABLED_LANGUAGES"); + } } #[test] #[serial] fn test_get_enabled_languages_multiple() { - std::env::set_var("ENABLED_LANGUAGES", "python,rust,typescript"); + unsafe { + std::env::set_var("ENABLED_LANGUAGES", "python,rust,typescript"); + } let result = ContainerOrchestrator::get_enabled_languages(); assert!(result.is_some()); @@ -1065,13 +1073,17 @@ mod tests { assert!(languages.contains(&SupportedLanguages::Rust)); assert!(languages.contains(&SupportedLanguages::TypeScriptJavaScript)); - std::env::remove_var("ENABLED_LANGUAGES"); + unsafe { + std::env::remove_var("ENABLED_LANGUAGES"); + } } #[test] #[serial] fn test_get_enabled_languages_with_spaces() { - std::env::set_var("ENABLED_LANGUAGES", " python , rust , go "); + unsafe { + std::env::set_var("ENABLED_LANGUAGES", " python , rust , go "); + } let result = ContainerOrchestrator::get_enabled_languages(); assert!(result.is_some()); @@ -1082,13 +1094,17 @@ mod tests { assert!(languages.contains(&SupportedLanguages::Rust)); assert!(languages.contains(&SupportedLanguages::Golang)); - std::env::remove_var("ENABLED_LANGUAGES"); + unsafe { + std::env::remove_var("ENABLED_LANGUAGES"); + } } #[test] #[serial] fn test_get_enabled_languages_with_invalid() { - std::env::set_var("ENABLED_LANGUAGES", "python,invalid,rust"); + unsafe { + std::env::set_var("ENABLED_LANGUAGES", "python,invalid,rust"); + } let result = ContainerOrchestrator::get_enabled_languages(); assert!(result.is_some()); @@ -1099,13 +1115,17 @@ mod tests { assert!(languages.contains(&SupportedLanguages::Python)); assert!(languages.contains(&SupportedLanguages::Rust)); - std::env::remove_var("ENABLED_LANGUAGES"); + unsafe { + std::env::remove_var("ENABLED_LANGUAGES"); + } } #[test] #[serial] fn test_get_enabled_languages_empty_string() { - std::env::set_var("ENABLED_LANGUAGES", ""); + unsafe { + std::env::set_var("ENABLED_LANGUAGES", ""); + } let result = ContainerOrchestrator::get_enabled_languages(); assert!(result.is_some()); @@ -1114,6 +1134,8 @@ mod tests { // Empty string results in empty set assert_eq!(languages.len(), 0); - std::env::remove_var("ENABLED_LANGUAGES"); + unsafe { + std::env::remove_var("ENABLED_LANGUAGES"); + } } } From a0d001780d8f1bd0c7b47c6efa320addda639bd9 Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Wed, 3 Dec 2025 15:10:10 +0100 Subject: [PATCH 51/92] Make health check part of `spawn_container`` This ensures the container is always healthy once spawn returns, regardless of whether it was spawned in `initialize_workspace` or elsewhere. --- crates/proxy/src/container/mod.rs | 26 ---------------------- crates/proxy/src/container/orchestrator.rs | 17 ++++++++++++++ 2 files changed, 17 insertions(+), 26 deletions(-) diff --git a/crates/proxy/src/container/mod.rs b/crates/proxy/src/container/mod.rs index f7f67a87..051c83f0 100644 --- a/crates/proxy/src/container/mod.rs +++ b/crates/proxy/src/container/mod.rs @@ -489,32 +489,6 @@ impl ContainerOrchestrator { language, info.endpoint ); - - // Spawn health check in background - let orchestrator = self.clone(); - let info_clone = info.clone(); - tokio::spawn(async move { - match orchestrator.check_container_health(&info_clone).await { - Ok(_) => { - log::info!("{} is now healthy and ready", info_clone.image_name); - orchestrator - .set_container_health( - language.clone(), - ContainerHealthStatus::Healthy, - ) - .await; - } - Err(e) => { - log::error!("{} health check failed: {}", info_clone.image_name, e); - orchestrator - .set_container_health( - language.clone(), - ContainerHealthStatus::Unhealthy, - ) - .await; - } - } - }); } Err(e) => { log::error!("Failed to spawn container for {:?}: {}", language, e); diff --git a/crates/proxy/src/container/orchestrator.rs b/crates/proxy/src/container/orchestrator.rs index e4416892..9f7aff00 100644 --- a/crates/proxy/src/container/orchestrator.rs +++ b/crates/proxy/src/container/orchestrator.rs @@ -338,6 +338,23 @@ impl ContainerOrchestrator { endpoint ); + match self.check_container_health(&info).await { + Ok(_) => { + log::info!("{} is now healthy and ready", info.image_name); + self.set_container_health(language.clone(), ContainerHealthStatus::Healthy) + .await; + } + Err(e) => { + log::error!("{} health check failed: {}", info.image_name, e); + self.set_container_health(language.clone(), ContainerHealthStatus::Unhealthy) + .await; + return Err(OrchestratorError::HealthCheck(format!( + "{}: {}", + info.image_name, e + ))); + } + } + Ok(info) } From 83ada33bf46e1f4d127990cab82d41490b617e88 Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Wed, 3 Dec 2025 15:43:51 +0100 Subject: [PATCH 52/92] Wait until server is healthy before processing messages --- clients/typescript/src/server.ts | 38 ++++++++++++++++++++++++++++---- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/clients/typescript/src/server.ts b/clients/typescript/src/server.ts index 05d95d9c..47d80f52 100644 --- a/clients/typescript/src/server.ts +++ b/clients/typescript/src/server.ts @@ -46,11 +46,10 @@ class LspServer { } async run(): Promise { - await this.startServer(); - this.setupSignalHandlers(); - await this.sendLogMessage(MessageType.Info, "Nuanced LSP started"); - try { + await this.startServer(); + this.setupSignalHandlers(); + await this.sendLogMessage(MessageType.Info, "Nuanced LSP started"); await this.processMessages(); } catch (err) { await this.sendLogMessage( @@ -87,10 +86,41 @@ class LspServer { throw new Error("Failed to start container"); } + await this.waitUntilServerIsHealthy(); + this.ws = await this.client.lsp_ws(); await this.connectWebSocket(); } + private async waitUntilServerIsHealthy(): Promise { + const timeoutMs = 60000; + const initialDelayMs = 100; + const maxDelayMs = 5000; + const startTime = Date.now(); + + let attempt = 0; + while (Date.now() - startTime < timeoutMs) { + const result = await this.client.health(); + + if (result.ok) { + if (result.data.status === "ok") { + return; + } else if (result.data.status === "not ok") { + throw new Error("Server is unhealthy"); + } + } + + const delayMs = Math.min( + initialDelayMs * Math.pow(2, attempt), + maxDelayMs, + ); + await new Promise((resolve) => setTimeout(resolve, delayMs)); + attempt++; + } + + throw new Error("Server health check timed out after 60s"); + } + private async connectWebSocket(): Promise { return new Promise((resolve, reject) => { if (!this.ws) { From 2eb1346976adbada27797152ad2baa588f3e9a37 Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Wed, 3 Dec 2025 16:54:11 +0100 Subject: [PATCH 53/92] Extract capabilities definition from message handling --- crates/proxy/src/handlers/lsp.rs | 76 +++++++++++---------- crates/wrapper/src/lsp/client.rs | 67 +++++++++--------- crates/wrapper/src/lsp/languages/generic.rs | 3 +- crates/wrapper/src/lsp/languages/golang.rs | 3 +- crates/wrapper/src/lsp/languages/sorbet.rs | 3 +- 5 files changed, 79 insertions(+), 73 deletions(-) diff --git a/crates/proxy/src/handlers/lsp.rs b/crates/proxy/src/handlers/lsp.rs index b6649646..da33fe00 100644 --- a/crates/proxy/src/handlers/lsp.rs +++ b/crates/proxy/src/handlers/lsp.rs @@ -1,6 +1,3 @@ -use crate::container::ContainerOrchestrator; -use crate::handlers::container_proxy; -use crate::AppState; use actix_web::web::{Data, Json}; use actix_web::HttpResponse; use common::api_types::{JsonRpcErrorCode, JsonRpcMessage}; @@ -12,8 +9,37 @@ use lsp_types::{ }; use serde_json::Value; use std::sync::Arc; +use std::sync::LazyLock; use url::Url; +use crate::container::ContainerOrchestrator; +use crate::handlers::container_proxy; +use crate::AppState; + +static SERVER_CAPABILITES: LazyLock = LazyLock::new(|| { + let mut capabilities = ServerCapabilities::default(); + capabilities.call_hierarchy_provider = Some(true.into()); + capabilities.declaration_provider = Some(DeclarationCapability::Simple(true)); + capabilities.definition_provider = Some(OneOf::Left(true)); + capabilities.document_symbol_provider = Some(OneOf::Left(true)); + capabilities.folding_range_provider = Some(FoldingRangeProviderCapability::Simple(true)); + capabilities.hover_provider = Some(HoverProviderCapability::Simple(false)); + capabilities.implementation_provider = Some(ImplementationProviderCapability::Simple(true)); + capabilities.inline_value_provider = Some(OneOf::Left(true)); + capabilities.position_encoding = Some(PositionEncodingKind::UTF16); + capabilities.references_provider = Some(OneOf::Left(true)); + capabilities.text_document_sync = Some( + TextDocumentSyncOptions { + open_close: Some(true), + change: Some(TextDocumentSyncKind::FULL), + ..Default::default() + } + .into(), + ); + capabilities.type_definition_provider = Some(true.into()); + capabilities +}); + /// Forward LSP JSON-RPC requests to language servers #[utoipa::path( post, @@ -160,40 +186,16 @@ pub fn handle_lifecycle_request(request: &JsonRpcMessage) -> Result { - let mut capabilities = ServerCapabilities::default(); - capabilities.call_hierarchy_provider = Some(true.into()); - capabilities.declaration_provider = Some(DeclarationCapability::Simple(true)); - capabilities.definition_provider = Some(OneOf::Left(true)); - capabilities.document_symbol_provider = Some(OneOf::Left(true)); - capabilities.folding_range_provider = - Some(FoldingRangeProviderCapability::Simple(true)); - capabilities.hover_provider = Some(HoverProviderCapability::Simple(false)); - capabilities.implementation_provider = - Some(ImplementationProviderCapability::Simple(true)); - capabilities.inline_value_provider = Some(OneOf::Left(true)); - capabilities.position_encoding = Some(PositionEncodingKind::UTF16); - capabilities.references_provider = Some(OneOf::Left(true)); - capabilities.text_document_sync = Some( - TextDocumentSyncOptions { - open_close: Some(true), - change: Some(TextDocumentSyncKind::FULL), - ..Default::default() - } - .into(), - ); - capabilities.type_definition_provider = Some(true.into()); - Some(JsonRpcMessage::new_result_response( - req_id, - InitializeResult { - capabilities, - server_info: Some(ServerInfo { - name: "nuanced-lsp".to_string(), - version: Some(env!("CARGO_PKG_VERSION").to_string()), - }), - }, - )) - } + "initialize" => Some(JsonRpcMessage::new_result_response( + req_id, + InitializeResult { + capabilities: SERVER_CAPABILITES.clone(), + server_info: Some(ServerInfo { + name: "nuanced-lsp".to_string(), + version: Some(env!("CARGO_PKG_VERSION").to_string()), + }), + }, + )), "initialized" | "exit" => None, // No response for notifications "shutdown" => Some(JsonRpcMessage::new_result_response(req_id, Value::Null)), dollar_method if dollar_method.starts_with("$/") => { diff --git a/crates/wrapper/src/lsp/client.rs b/crates/wrapper/src/lsp/client.rs index de957f6a..a16315fb 100644 --- a/crates/wrapper/src/lsp/client.rs +++ b/crates/wrapper/src/lsp/client.rs @@ -1,6 +1,3 @@ -use crate::lsp::json_rpc::JsonRpc; -use crate::lsp::process::Process; -use crate::lsp::{ExpectedMessageKey, JsonRpcHandler, ProcessHandler}; use async_trait::async_trait; use common::utils::file_utils::{fix_relative_uris, search_paths, FileType}; use common::utils::language_utils::detect_language_string; @@ -15,12 +12,45 @@ use lsp_types::{ }; use std::error::Error; use std::path::{Path, PathBuf}; +use std::sync::LazyLock; use common::utils::workspace_documents::{ DidOpenConfiguration, WorkspaceDocuments, WorkspaceDocumentsHandler, DEFAULT_EXCLUDE_PATTERNS, }; use super::PendingRequests; +use crate::lsp::json_rpc::JsonRpc; +use crate::lsp::process::Process; +use crate::lsp::{ExpectedMessageKey, JsonRpcHandler, ProcessHandler}; + +pub(crate) static CLIENT_CAPABILITES: LazyLock = LazyLock::new(|| { + let mut capabilities = ClientCapabilities::default(); + capabilities.general = Some(GeneralClientCapabilities { + position_encodings: Some(vec![PositionEncodingKind::UTF16]), + ..Default::default() + }); + capabilities.text_document = Some(TextDocumentClientCapabilities { + document_symbol: Some(DocumentSymbolClientCapabilities { + dynamic_registration: Some(false), + hierarchical_document_symbol_support: Some(true), + ..Default::default() + }), + // Turn off diagnostics for performance, we don't use them at the moment + publish_diagnostics: Some(PublishDiagnosticsClientCapabilities { + related_information: Some(false), + tag_support: Some(TagSupport { value_set: vec![] }), + code_description_support: Some(false), + data_support: Some(false), + version_support: Some(false), + }), + ..Default::default() + }); + + capabilities.experimental = Some(serde_json::json!({ + "serverStatusNotification": true + })); + capabilities +}); #[async_trait] pub trait LspClient: Send { @@ -43,35 +73,6 @@ pub trait LspClient: Send { Ok(init_result) } - fn get_capabilities(&mut self) -> ClientCapabilities { - let mut capabilities = ClientCapabilities::default(); - capabilities.general = Some(GeneralClientCapabilities { - position_encodings: Some(vec![PositionEncodingKind::UTF16]), - ..Default::default() - }); - capabilities.text_document = Some(TextDocumentClientCapabilities { - document_symbol: Some(DocumentSymbolClientCapabilities { - dynamic_registration: Some(false), - hierarchical_document_symbol_support: Some(true), - ..Default::default() - }), - // Turn off diagnostics for performance, we don't use them at the moment - publish_diagnostics: Some(PublishDiagnosticsClientCapabilities { - related_information: Some(false), - tag_support: Some(TagSupport { value_set: vec![] }), - code_description_support: Some(false), - data_support: Some(false), - version_support: Some(false), - }), - ..Default::default() - }); - - capabilities.experimental = Some(serde_json::json!({ - "serverStatusNotification": true - })); - capabilities - } - async fn get_initialize_params( &mut self, root_path: String, @@ -79,7 +80,7 @@ pub trait LspClient: Send { let workspace_folders = self.find_workspace_folders(root_path.clone()).await?; #[allow(deprecated)] let params = InitializeParams { - capabilities: self.get_capabilities(), + capabilities: CLIENT_CAPABILITES.clone(), workspace_folders: Some(workspace_folders), root_uri: Some(Url::from_file_path(&root_path).unwrap()), // primarily for python ..Default::default() diff --git a/crates/wrapper/src/lsp/languages/generic.rs b/crates/wrapper/src/lsp/languages/generic.rs index 8a670022..5b8bc484 100644 --- a/crates/wrapper/src/lsp/languages/generic.rs +++ b/crates/wrapper/src/lsp/languages/generic.rs @@ -1,5 +1,6 @@ use std::path::Path; +use crate::lsp::client::CLIENT_CAPABILITES; use crate::lsp::{JsonRpcHandler, LspClient, PendingRequests, ProcessHandler}; use async_trait::async_trait; @@ -53,7 +54,7 @@ impl LspClient for GenericLspClient { ) -> Result> { let workspace_folders = self.find_workspace_folders(root_path.clone()).await?; Ok(InitializeParams { - capabilities: self.get_capabilities(), + capabilities: CLIENT_CAPABILITES.clone(), workspace_folders: Some(workspace_folders), root_uri: Some(lsp_types::Url::from_file_path(&root_path).unwrap()), initialization_options: self.initialization_options.clone(), diff --git a/crates/wrapper/src/lsp/languages/golang.rs b/crates/wrapper/src/lsp/languages/golang.rs index 83cc0367..7d45bcfd 100644 --- a/crates/wrapper/src/lsp/languages/golang.rs +++ b/crates/wrapper/src/lsp/languages/golang.rs @@ -1,5 +1,6 @@ use std::path::{Path, PathBuf}; +use crate::lsp::client::CLIENT_CAPABILITES; use crate::lsp::{JsonRpcHandler, LspClient, PendingRequests, ProcessHandler}; use async_trait::async_trait; @@ -53,7 +54,7 @@ impl LspClient for GoplsClient { let workspace_folders = self.find_workspace_folders(root_path.clone()).await?; Ok(InitializeParams { - capabilities: self.get_capabilities(), + capabilities: CLIENT_CAPABILITES.clone(), // Prefer workspaceFolders; do not also set root_uri to avoid confusion workspace_folders: Some(workspace_folders), root_uri: None, diff --git a/crates/wrapper/src/lsp/languages/sorbet.rs b/crates/wrapper/src/lsp/languages/sorbet.rs index 7635c1ca..2fa5553d 100644 --- a/crates/wrapper/src/lsp/languages/sorbet.rs +++ b/crates/wrapper/src/lsp/languages/sorbet.rs @@ -1,6 +1,7 @@ use std::fs; use std::path::PathBuf; +use crate::lsp::client::CLIENT_CAPABILITES; use crate::lsp::{JsonRpcHandler, LspClient, PendingRequests, ProcessHandler}; use async_trait::async_trait; @@ -61,7 +62,7 @@ impl LspClient for SorbetClient { }); Ok(InitializeParams { - capabilities: self.get_capabilities(), + capabilities: CLIENT_CAPABILITES.clone(), workspace_folders: Some(workspace_folders), root_uri: None, initialization_options: Some(init_options), From d9c3db74b0d105f580540832835b7d9e6fb2457a Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Thu, 4 Dec 2025 13:11:40 +0100 Subject: [PATCH 54/92] Refactor LspClient into a struct type and push language config into LspConfig --- crates/wrapper/src/lsp/client.rs | 238 +++++++++----------- crates/wrapper/src/lsp/languages/generic.rs | 188 ++++++++-------- crates/wrapper/src/lsp/languages/golang.rs | 97 +++----- crates/wrapper/src/lsp/languages/mod.rs | 124 +++++++++- crates/wrapper/src/lsp/languages/python.rs | 181 +++++++-------- crates/wrapper/src/lsp/languages/sorbet.rs | 108 +++------ crates/wrapper/src/lsp/mod.rs | 1 - crates/wrapper/src/main.rs | 111 +++------ crates/wrapper/src/managers/api.rs | 10 +- crates/wrapper/src/managers/ws.rs | 12 +- 10 files changed, 529 insertions(+), 541 deletions(-) diff --git a/crates/wrapper/src/lsp/client.rs b/crates/wrapper/src/lsp/client.rs index a16315fb..2c431cdf 100644 --- a/crates/wrapper/src/lsp/client.rs +++ b/crates/wrapper/src/lsp/client.rs @@ -1,5 +1,5 @@ use async_trait::async_trait; -use common::utils::file_utils::{fix_relative_uris, search_paths, FileType}; +use common::utils::file_utils::fix_relative_uris; use common::utils::language_utils::detect_language_string; use log::{debug, error, info, warn}; use lsp_types::{ @@ -8,14 +8,14 @@ use lsp_types::{ InitializeResult, Location, PartialResultParams, Position, PositionEncodingKind, PublishDiagnosticsClientCapabilities, ReferenceContext, ReferenceParams, TagSupport, TextDocumentClientCapabilities, TextDocumentIdentifier, TextDocumentItem, - TextDocumentPositionParams, Url, WorkDoneProgressParams, WorkspaceFolder, + TextDocumentPositionParams, Url, WorkDoneProgressParams, }; use std::error::Error; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use std::sync::LazyLock; use common::utils::workspace_documents::{ - DidOpenConfiguration, WorkspaceDocuments, WorkspaceDocumentsHandler, DEFAULT_EXCLUDE_PATTERNS, + DidOpenConfiguration, WorkspaceDocuments, WorkspaceDocumentsHandler, }; use super::PendingRequests; @@ -23,6 +23,28 @@ use crate::lsp::json_rpc::JsonRpc; use crate::lsp::process::Process; use crate::lsp::{ExpectedMessageKey, JsonRpcHandler, ProcessHandler}; +#[async_trait] +pub trait LspConfig: Send + Sync { + async fn get_initialize_params( + &mut self, + root_path: String, + ) -> Result>; + + fn get_setup_workspace_method(&self) -> Option { + None + } + + fn get_root_files(&mut self) -> Vec { + vec![".git".to_string()] + } + + fn include_patterns(&self) -> Vec; + + fn exclude_patterns(&self) -> Vec; + + fn did_open_configuration(&self) -> DidOpenConfiguration; +} + pub(crate) static CLIENT_CAPABILITES: LazyLock = LazyLock::new(|| { let mut capabilities = ClientCapabilities::default(); capabilities.general = Some(GeneralClientCapabilities { @@ -52,16 +74,54 @@ pub(crate) static CLIENT_CAPABILITES: LazyLock = LazyLock::n capabilities }); -#[async_trait] -pub trait LspClient: Send { - async fn initialize( +pub struct LspClient { + config: Box, + process: ProcessHandler, + json_rpc: JsonRpcHandler, + workspace_documents: WorkspaceDocumentsHandler, + pending_requests: PendingRequests, + unexpected_notifications_tx: tokio::sync::broadcast::Sender, +} + +impl LspClient { + pub fn new(config: Box, process: ProcessHandler, root_path: &str) -> Self { + let json_rpc = JsonRpcHandler::new(); + let pending_requests = PendingRequests::new(); + let (unexpected_notifications_tx, _) = + tokio::sync::broadcast::channel::(1); + let (_, workspace_docs_rx) = + tokio::sync::broadcast::channel::(1); + + let include_patterns = config.include_patterns(); + let exclude_patterns = config.exclude_patterns(); + let did_open_configuration = config.did_open_configuration(); + + let workspace_documents = WorkspaceDocumentsHandler::new( + std::path::Path::new(root_path), + include_patterns, + exclude_patterns, + workspace_docs_rx, + did_open_configuration, + ); + + Self { + config, + process, + json_rpc, + workspace_documents, + pending_requests, + unexpected_notifications_tx, + } + } + + pub async fn initialize( &mut self, root_path: String, ) -> Result> { info!("Initializing LSP client with root path: {:?}", root_path); self.start_response_listener().await?; - let params = self.get_initialize_params(root_path.clone()).await?; + let params = self.config.get_initialize_params(root_path.clone()).await?; let result = self .send_request("initialize", Some(serde_json::to_value(params)?)) @@ -73,43 +133,28 @@ pub trait LspClient: Send { Ok(init_result) } - async fn get_initialize_params( - &mut self, - root_path: String, - ) -> Result> { - let workspace_folders = self.find_workspace_folders(root_path.clone()).await?; - #[allow(deprecated)] - let params = InitializeParams { - capabilities: CLIENT_CAPABILITES.clone(), - workspace_folders: Some(workspace_folders), - root_uri: Some(Url::from_file_path(&root_path).unwrap()), // primarily for python - ..Default::default() - }; - Ok(params) - } - - async fn send_notification( + pub async fn send_notification( &mut self, method: &str, params: Option, ) -> Result<(), Box> { - let notification = self.get_json_rpc().create_notification(method, params); + let notification = self.json_rpc.create_notification(method, params); debug!("Sending notification: {}", method); - self.get_process().send(¬ification).await?; + self.process.send(¬ification).await?; Ok(()) } - async fn send_request( + pub async fn send_request( &mut self, method: &str, params: Option, ) -> Result> { - let (id, request) = self.get_json_rpc().create_request(method, params); + let (id, request) = self.json_rpc.create_request(method, params); - let mut response_receiver = self.get_pending_requests().add_request(id).await?; + let mut response_receiver = self.pending_requests.add_request(id).await?; debug!("Sending request {}: {}", id, method); - self.get_process().send(&request).await?; + self.process.send(&request).await?; let response = response_receiver .recv() @@ -130,10 +175,10 @@ pub trait LspClient: Send { } async fn start_response_listener(&mut self) -> Result<(), Box> { - let mut process = self.get_process().clone(); - let pending_requests = self.get_pending_requests().clone(); - let json_rpc = self.get_json_rpc().clone(); - let notification_channel = self.get_unexpected_notifications_tx(); + let mut process = self.process.clone(); + let pending_requests = self.pending_requests.clone(); + let json_rpc = self.json_rpc.clone(); + let notification_channel = self.unexpected_notifications_tx.clone(); tokio::spawn(async move { loop { @@ -199,7 +244,7 @@ pub trait LspClient: Send { .await } - async fn text_document_definition( + pub async fn text_document_definition( &mut self, file_path: &str, position: Position, @@ -210,7 +255,7 @@ pub trait LspClient: Send { ); let needs_open = { - let workspace_documents = self.get_workspace_documents(); + let workspace_documents = &self.workspace_documents; workspace_documents.get_did_open_configuration() == DidOpenConfiguration::Lazy && !workspace_documents.is_did_open_document(file_path) }; @@ -219,7 +264,7 @@ pub trait LspClient: Send { if needs_open { info!("Sending textDocument/didOpen for {}", file_path); let document_text = self - .get_workspace_documents() + .workspace_documents .read_text_document(&PathBuf::from(file_path), None) .await?; @@ -231,8 +276,7 @@ pub trait LspClient: Send { }) .await?; - self.get_workspace_documents() - .add_did_open_document(file_path); + self.workspace_documents.add_did_open_document(file_path); } let params = GotoDefinitionParams { @@ -259,7 +303,7 @@ pub trait LspClient: Send { } else { // Pre-process the result to fix relative URIs (Sorbet issue) let workspace_path = self - .get_workspace_documents() + .workspace_documents .root_path() .to_str() .ok_or("Invalid workspace path")?; @@ -303,14 +347,14 @@ pub trait LspClient: Send { Ok(goto_resp) } - async fn text_document_reference( + pub async fn text_document_reference( &mut self, file_path: &str, position: Position, ) -> Result, Box> { // Get the configuration and check if document is opened first let needs_open = { - let workspace_documents = self.get_workspace_documents(); + let workspace_documents = &self.workspace_documents; workspace_documents.get_did_open_configuration() == DidOpenConfiguration::Lazy && !workspace_documents.is_did_open_document(file_path) }; @@ -319,7 +363,7 @@ pub trait LspClient: Send { if needs_open { info!("Sending textDocument/didOpen for {}", file_path); let document_text = self - .get_workspace_documents() + .workspace_documents .read_text_document(&PathBuf::from(file_path), None) .await?; @@ -331,8 +375,7 @@ pub trait LspClient: Send { }) .await?; - self.get_workspace_documents() - .add_did_open_document(file_path); + self.workspace_documents.add_did_open_document(file_path); } let params = ReferenceParams { @@ -361,7 +404,7 @@ pub trait LspClient: Send { } else { // Pre-process the result to fix relative URIs (Sorbet issue) let workspace_path = self - .get_workspace_documents() + .workspace_documents .root_path() .to_str() .ok_or("Invalid workspace path")?; @@ -372,101 +415,34 @@ pub trait LspClient: Send { Ok(ref_resp) } - fn get_process(&mut self) -> &mut ProcessHandler; - - fn get_json_rpc(&mut self) -> &mut JsonRpcHandler; - - fn get_root_files(&mut self) -> Vec { - vec![".git".to_string()] - } - - fn get_pending_requests(&mut self) -> &mut PendingRequests; - - fn get_workspace_documents(&mut self) -> &mut WorkspaceDocumentsHandler; - - fn get_unexpected_notifications_tx( - &self, - ) -> tokio::sync::broadcast::Sender; - - fn subscribe_to_unexpected_notifications( + pub fn subscribe_to_unexpected_notifications( &self, ) -> tokio::sync::broadcast::Receiver { - self.get_unexpected_notifications_tx().subscribe() + self.unexpected_notifications_tx.subscribe() } - /// Sets up the workspace for the language server. - /// - /// Some language servers require specific commands to be run before - /// workspace-wide features are available. For example: - /// - TypeScript Language Server needs an explicit didOpen notification for each file - /// - Rust Analyzer needs a reloadWorkspace command - /// - /// # Arguments - /// - /// * `root_path` - The root path of the workspace - /// - /// # Returns - /// - /// A Result containing () if successful, or a boxed Error if an error occurred - #[allow(unused)] - async fn setup_workspace( + pub async fn setup_workspace( &mut self, - root_path: &str, + _root_path: &str, ) -> Result<(), Box> { + if let Some(method) = self.config.get_setup_workspace_method() { + info!("Calling setup workspace method: {}", method); + self.send_request(&method, None).await?; + } Ok(()) } - async fn find_workspace_folders( + pub async fn expect_notification( &mut self, - root_path: String, - ) -> Result, Box> { - let mut workspace_folders: Vec = Vec::new(); - let include_patterns = self - .get_root_files() - .into_iter() - .map(|f| format!("**/{f}")) - .collect(); - let exclude_patterns = DEFAULT_EXCLUDE_PATTERNS - .iter() - .map(|&s| s.to_string()) - .collect(); - - match search_paths( - Path::new(&root_path), - include_patterns, - exclude_patterns, - true, - FileType::Dir, - ) { - Ok(dirs) => { - for dir in dirs { - let folder_path = Path::new(&root_path).join(&dir); - if let Ok(uri) = Url::from_file_path(&folder_path) { - workspace_folders.push(WorkspaceFolder { - uri, - name: folder_path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("") - .to_string(), - }); - } - } - } - Err(e) => return Err(Box::new(e)), - } - - if workspace_folders.is_empty() { - // Fallback: use the root_path itself as a workspace folder - warn!("No workspace folders found. Using root path as workspace."); - if let Ok(uri) = Url::from_file_path(&root_path) { - workspace_folders.push(WorkspaceFolder { - uri, - name: root_path.to_string(), - }); - } - } + key: ExpectedMessageKey, + ) -> Result< + tokio::sync::broadcast::Receiver, + Box, + > { + self.pending_requests.add_notification(key).await + } - Ok(workspace_folders.into_iter().collect()) + pub fn get_workspace_documents(&self) -> &WorkspaceDocumentsHandler { + &self.workspace_documents } } diff --git a/crates/wrapper/src/lsp/languages/generic.rs b/crates/wrapper/src/lsp/languages/generic.rs index 5b8bc484..65a54f96 100644 --- a/crates/wrapper/src/lsp/languages/generic.rs +++ b/crates/wrapper/src/lsp/languages/generic.rs @@ -1,53 +1,24 @@ -use std::path::Path; - -use crate::lsp::client::CLIENT_CAPABILITES; -use crate::lsp::{JsonRpcHandler, LspClient, PendingRequests, ProcessHandler}; +use crate::lsp::client::{LspConfig, CLIENT_CAPABILITES}; use async_trait::async_trait; -use common::api_types::JsonRpcMessage; -use common::utils::workspace_documents::{ - DidOpenConfiguration, WorkspaceDocumentsHandler, DEFAULT_EXCLUDE_PATTERNS, -}; -use lsp_types::InitializeParams; +use common::utils::file_utils::{search_paths, FileType}; +use common::utils::workspace_documents::{DidOpenConfiguration, DEFAULT_EXCLUDE_PATTERNS}; +use log::warn; +use lsp_types::{InitializeParams, Url, WorkspaceFolder}; use std::error::Error; +use std::path::Path; -pub struct GenericLspClient { - process: ProcessHandler, - json_rpc: JsonRpcHandler, - workspace_documents: WorkspaceDocumentsHandler, - pending_requests: PendingRequests, - unexpected_notifications_tx: tokio::sync::broadcast::Sender, +#[derive(Clone)] +pub struct GenericConfig { initialization_options: Option, setup_workspace_method: Option, + file_patterns: Vec, + exclude_patterns: Vec, + did_open_configuration: DidOpenConfiguration, } #[async_trait] -impl LspClient for GenericLspClient { - fn get_process(&mut self) -> &mut ProcessHandler { - &mut self.process - } - - fn get_json_rpc(&mut self) -> &mut JsonRpcHandler { - &mut self.json_rpc - } - - fn get_root_files(&mut self) -> Vec { - vec![] // Generic client doesn't specify root files - } - - fn get_workspace_documents(&mut self) -> &mut WorkspaceDocumentsHandler { - &mut self.workspace_documents - } - - fn get_pending_requests(&mut self) -> &mut PendingRequests { - &mut self.pending_requests - } - - fn get_unexpected_notifications_tx(&self) -> tokio::sync::broadcast::Sender { - self.unexpected_notifications_tx.clone() - } - - #[allow(deprecated)] +impl LspConfig for GenericConfig { async fn get_initialize_params( &mut self, root_path: String, @@ -56,86 +27,109 @@ impl LspClient for GenericLspClient { Ok(InitializeParams { capabilities: CLIENT_CAPABILITES.clone(), workspace_folders: Some(workspace_folders), - root_uri: Some(lsp_types::Url::from_file_path(&root_path).unwrap()), + #[allow(deprecated)] + root_uri: Some(Url::from_file_path(&root_path).unwrap()), initialization_options: self.initialization_options.clone(), ..Default::default() }) } - async fn setup_workspace( - &mut self, - _root_path: &str, - ) -> Result<(), Box> { - if let Some(method) = self.setup_workspace_method.clone() { - log::info!("Calling setup workspace method: {}", method); - self.send_request(&method, None).await?; - } - Ok(()) + fn get_setup_workspace_method(&self) -> Option { + self.setup_workspace_method.clone() + } + + fn get_root_files(&mut self) -> Vec { + vec![] + } + + fn include_patterns(&self) -> Vec { + self.file_patterns.clone() + } + + fn exclude_patterns(&self) -> Vec { + self.exclude_patterns.clone() + } + + fn did_open_configuration(&self) -> DidOpenConfiguration { + self.did_open_configuration.clone() } } -impl GenericLspClient { - /// Create a new GenericLspClient with configurable file patterns and did-open behavior +impl GenericConfig { pub fn new( - process: ProcessHandler, - root_path: String, file_patterns: Vec, - did_open_config: DidOpenConfiguration, + exclude_patterns: Vec, + did_open_configuration: DidOpenConfiguration, ) -> Self { - let (_, workspace_docs_rx) = tokio::sync::broadcast::channel(1); - let workspace_documents = WorkspaceDocumentsHandler::new( - Path::new(&root_path), - file_patterns, - DEFAULT_EXCLUDE_PATTERNS - .iter() - .map(|&s| s.to_string()) - .collect(), - workspace_docs_rx, - did_open_config, - ); - - let json_rpc_handler = JsonRpcHandler::new(); - - let (unexpected_notifications_tx, _) = tokio::sync::broadcast::channel(1); - Self { - process, - json_rpc: json_rpc_handler, - workspace_documents, - pending_requests: PendingRequests::new(), - unexpected_notifications_tx, initialization_options: None, setup_workspace_method: None, + file_patterns, + exclude_patterns, + did_open_configuration, } } - /// Set initialization options for the LSP server (e.g., Rust cargo.sysroot) + async fn find_workspace_folders( + &mut self, + root_path: String, + ) -> Result, Box> { + let mut workspace_folders: Vec = Vec::new(); + let include_patterns = self + .get_root_files() + .into_iter() + .map(|f| format!("**/{f}")) + .collect(); + let exclude_patterns = DEFAULT_EXCLUDE_PATTERNS + .iter() + .map(|&s| s.to_string()) + .collect(); + + match search_paths( + Path::new(&root_path), + include_patterns, + exclude_patterns, + true, + FileType::Dir, + ) { + Ok(dirs) => { + for dir in dirs { + let folder_path = Path::new(&root_path).join(&dir); + if let Ok(uri) = Url::from_file_path(&folder_path) { + workspace_folders.push(WorkspaceFolder { + uri, + name: folder_path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("") + .to_string(), + }); + } + } + } + Err(e) => return Err(Box::new(e)), + } + + if workspace_folders.is_empty() { + warn!("No workspace folders found. Using root path as workspace."); + if let Ok(uri) = Url::from_file_path(&root_path) { + workspace_folders.push(WorkspaceFolder { + uri, + name: root_path.to_string(), + }); + } + } + + Ok(workspace_folders.into_iter().collect()) + } + pub fn with_initialization_options(mut self, options: serde_json::Value) -> Self { self.initialization_options = Some(options); self } - /// Set setup workspace method to call after initialization (e.g., rust-analyzer/reloadWorkspace) pub fn with_setup_workspace_method(mut self, method: String) -> Self { self.setup_workspace_method = Some(method); self } - - /// Extract all components at once (consumes self) - /// Returns (ProcessHandler, JsonRpcHandler, WorkspaceDocumentsHandler, PendingRequests) - pub fn into_components( - self, - ) -> ( - ProcessHandler, - JsonRpcHandler, - WorkspaceDocumentsHandler, - PendingRequests, - ) { - ( - self.process, - self.json_rpc, - self.workspace_documents, - self.pending_requests, - ) - } } diff --git a/crates/wrapper/src/lsp/languages/golang.rs b/crates/wrapper/src/lsp/languages/golang.rs index 7d45bcfd..47051086 100644 --- a/crates/wrapper/src/lsp/languages/golang.rs +++ b/crates/wrapper/src/lsp/languages/golang.rs @@ -1,52 +1,17 @@ -use std::path::{Path, PathBuf}; - -use crate::lsp::client::CLIENT_CAPABILITES; -use crate::lsp::{JsonRpcHandler, LspClient, PendingRequests, ProcessHandler}; +use crate::lsp::client::{LspConfig, CLIENT_CAPABILITES}; use async_trait::async_trait; -use common::api_types::JsonRpcMessage; +use common::utils::workspace_documents::{DidOpenConfiguration, GOLANG_FILE_PATTERNS}; use log::{info, warn}; use lsp_types::{InitializeParams, Url, WorkspaceFolder}; use std::error::Error; +use std::path::{Path, PathBuf}; -pub struct GoplsClient { - process: ProcessHandler, - json_rpc: JsonRpcHandler, - workspace_documents: common::utils::workspace_documents::WorkspaceDocumentsHandler, - pending_requests: PendingRequests, - unexpected_notifications_tx: tokio::sync::broadcast::Sender, -} +pub struct GoplsConfig; #[async_trait] -impl LspClient for GoplsClient { - fn get_process(&mut self) -> &mut ProcessHandler { - &mut self.process - } - - fn get_json_rpc(&mut self) -> &mut JsonRpcHandler { - &mut self.json_rpc - } - - fn get_root_files(&mut self) -> Vec { - vec![] // Gopls doesn't use root files in the new architecture - } - - fn get_workspace_documents( - &mut self, - ) -> &mut common::utils::workspace_documents::WorkspaceDocumentsHandler { - &mut self.workspace_documents - } - - fn get_pending_requests(&mut self) -> &mut PendingRequests { - &mut self.pending_requests - } - - fn get_unexpected_notifications_tx(&self) -> tokio::sync::broadcast::Sender { - self.unexpected_notifications_tx.clone() - } - +impl LspConfig for GoplsConfig { #[allow(deprecated)] - async fn get_initialize_params( &mut self, root_path: String, @@ -55,13 +20,40 @@ impl LspClient for GoplsClient { Ok(InitializeParams { capabilities: CLIENT_CAPABILITES.clone(), - // Prefer workspaceFolders; do not also set root_uri to avoid confusion workspace_folders: Some(workspace_folders), root_uri: None, ..Default::default() }) } + fn get_root_files(&mut self) -> Vec { + vec![] + } + + fn include_patterns(&self) -> Vec { + GOLANG_FILE_PATTERNS + .iter() + .map(|&s| s.to_string()) + .collect() + } + + fn exclude_patterns(&self) -> Vec { + common::utils::workspace_documents::DEFAULT_EXCLUDE_PATTERNS + .iter() + .map(|&s| s.to_string()) + .collect() + } + + fn did_open_configuration(&self) -> DidOpenConfiguration { + DidOpenConfiguration::None + } +} + +impl GoplsConfig { + pub fn new() -> Self { + Self + } + async fn find_workspace_folders( &mut self, root_path: String, @@ -122,31 +114,15 @@ impl LspClient for GoplsClient { } } -impl GoplsClient { - /// Create a new GoplsClient from the existing GenericLspClient components - pub fn new( - process: ProcessHandler, - json_rpc: JsonRpcHandler, - workspace_documents: common::utils::workspace_documents::WorkspaceDocumentsHandler, - pending_requests: PendingRequests, - ) -> Self { - let (unexpected_notifications_tx, _) = tokio::sync::broadcast::channel(1); - Self { - process, - json_rpc, - workspace_documents, - pending_requests, - unexpected_notifications_tx, - } +impl Default for GoplsConfig { + fn default() -> Self { + Self::new() } } -/// Walk upward from `start` to filesystem root, returning the first directory -/// that contains a child named `needle` (e.g., "go.work" or "go.mod"). fn nearest_ancestor_with(start: &Path, needle: &str) -> Option { let mut cur = start; - // If `start` is a file path, prefer its parent. if cur.is_file() { cur = cur.parent()?; } @@ -158,7 +134,6 @@ fn nearest_ancestor_with(start: &Path, needle: &str) -> Option { return Some(dir); } - // Stop at filesystem root if let Some(parent) = dir.parent() { dir = parent.to_path_buf(); } else { diff --git a/crates/wrapper/src/lsp/languages/mod.rs b/crates/wrapper/src/lsp/languages/mod.rs index def9cfd1..9e92bb3c 100644 --- a/crates/wrapper/src/lsp/languages/mod.rs +++ b/crates/wrapper/src/lsp/languages/mod.rs @@ -1,7 +1,125 @@ pub mod generic; pub mod golang; +#[allow(unused)] +pub mod python; pub mod sorbet; -pub use generic::GenericLspClient; -pub use golang::GoplsClient; -pub use sorbet::SorbetClient; +use common::utils::workspace_documents::{ + DidOpenConfiguration, CSHARP_FILE_PATTERNS, C_AND_CPP_FILE_PATTERNS, DEFAULT_EXCLUDE_PATTERNS, + JAVA_FILE_PATTERNS, PHP_FILE_PATTERNS, PYTHON_FILE_PATTERNS, RUBY_FILE_PATTERNS, + RUST_FILE_PATTERNS, TYPESCRIPT_AND_JAVASCRIPT_FILE_PATTERNS, +}; +use std::sync::LazyLock; + +pub use generic::GenericConfig; +pub use golang::GoplsConfig; +pub use sorbet::SorbetConfig; + +pub static PHP_CONFIG: LazyLock = LazyLock::new(|| { + GenericConfig::new( + PHP_FILE_PATTERNS.iter().map(|&s| s.to_string()).collect(), + DEFAULT_EXCLUDE_PATTERNS + .iter() + .map(|&s| s.to_string()) + .collect(), + DidOpenConfiguration::Lazy, + ) +}); + +pub static PYTHON_CONFIG: LazyLock = LazyLock::new(|| { + GenericConfig::new( + PYTHON_FILE_PATTERNS + .iter() + .map(|&s| s.to_string()) + .collect(), + DEFAULT_EXCLUDE_PATTERNS + .iter() + .map(|&s| s.to_string()) + .collect(), + DidOpenConfiguration::None, + ) +}); + +pub static RUBY_CONFIG: LazyLock = LazyLock::new(|| { + GenericConfig::new( + RUBY_FILE_PATTERNS.iter().map(|&s| s.to_string()).collect(), + DEFAULT_EXCLUDE_PATTERNS + .iter() + .map(|&s| s.to_string()) + .collect(), + DidOpenConfiguration::None, + ) +}); + +pub static TYPESCRIPT_AND_JAVASCRIPT_CONFIG: LazyLock = LazyLock::new(|| { + GenericConfig::new( + TYPESCRIPT_AND_JAVASCRIPT_FILE_PATTERNS + .iter() + .map(|&s| s.to_string()) + .collect(), + DEFAULT_EXCLUDE_PATTERNS + .iter() + .map(|&s| s.to_string()) + .collect(), + DidOpenConfiguration::Lazy, + ) +}); + +pub static RUST_CONFIG: LazyLock = LazyLock::new(|| { + GenericConfig::new( + RUST_FILE_PATTERNS.iter().map(|&s| s.to_string()).collect(), + DEFAULT_EXCLUDE_PATTERNS + .iter() + .map(|&s| s.to_string()) + .collect(), + DidOpenConfiguration::None, + ) + .with_initialization_options(serde_json::json!({ + "cargo": { + "sysroot": serde_json::Value::Null + } + })) + .with_setup_workspace_method("rust-analyzer/reloadWorkspace".to_string()) +}); + +pub static JAVA_CONFIG: LazyLock = LazyLock::new(|| { + GenericConfig::new( + JAVA_FILE_PATTERNS.iter().map(|&s| s.to_string()).collect(), + DEFAULT_EXCLUDE_PATTERNS + .iter() + .map(|&s| s.to_string()) + .collect(), + DidOpenConfiguration::None, + ) +}); + +pub static C_AND_CPP_CONFIG: LazyLock = LazyLock::new(|| { + GenericConfig::new( + C_AND_CPP_FILE_PATTERNS + .iter() + .map(|&s| s.to_string()) + .collect(), + DEFAULT_EXCLUDE_PATTERNS + .iter() + .map(|&s| s.to_string()) + .collect(), + DidOpenConfiguration::Lazy, + ) + .with_initialization_options(serde_json::json!({ + "clangdFileStatus": true + })) +}); + +pub static CSHARP_CONFIG: LazyLock = LazyLock::new(|| { + GenericConfig::new( + CSHARP_FILE_PATTERNS + .iter() + .map(|&s| s.to_string()) + .collect(), + DEFAULT_EXCLUDE_PATTERNS + .iter() + .map(|&s| s.to_string()) + .collect(), + DidOpenConfiguration::None, + ) +}); diff --git a/crates/wrapper/src/lsp/languages/python.rs b/crates/wrapper/src/lsp/languages/python.rs index b0891680..7d6257b5 100644 --- a/crates/wrapper/src/lsp/languages/python.rs +++ b/crates/wrapper/src/lsp/languages/python.rs @@ -1,116 +1,117 @@ -use std::{path::Path, process::Stdio}; +use crate::lsp::client::{LspConfig, CLIENT_CAPABILITES}; use async_trait::async_trait; -use notify_debouncer_mini::DebouncedEvent; -use tokio::process::Command; -use tokio::sync::broadcast::Receiver; - -use crate::lsp::{JsonRpcHandler, LspClient, PendingRequests, ProcessHandler}; - +use common::utils::file_utils::{search_paths, FileType}; use common::utils::workspace_documents::{ - DidOpenConfiguration, WorkspaceDocumentsHandler, DEFAULT_EXCLUDE_PATTERNS, - PYTHON_FILE_PATTERNS, PYTHON_ROOT_FILES, + DEFAULT_EXCLUDE_PATTERNS, PYTHON_FILE_PATTERNS, PYTHON_ROOT_FILES, }; +use log::warn; +use lsp_types::{InitializeParams, Url, WorkspaceFolder}; +use std::error::Error; +use std::path::Path; -pub struct JediClient { - process: ProcessHandler, - json_rpc: JsonRpcHandler, - workspace_documents: WorkspaceDocumentsHandler, - pending_requests: PendingRequests, -} +pub struct JediConfig; #[async_trait] -impl LspClient for JediClient { - fn get_process(&mut self) -> &mut ProcessHandler { - &mut self.process - } - - fn get_json_rpc(&mut self) -> &mut JsonRpcHandler { - &mut self.json_rpc +impl LspConfig for JediConfig { + #[allow(deprecated)] + async fn get_initialize_params( + &mut self, + root_path: String, + ) -> Result> { + let workspace_folders = self.find_workspace_folders(root_path.clone()).await?; + Ok(InitializeParams { + capabilities: CLIENT_CAPABILITES.clone(), + workspace_folders: Some(workspace_folders), + root_uri: Some(Url::from_file_path(&root_path).unwrap()), + ..Default::default() + }) } fn get_root_files(&mut self) -> Vec { PYTHON_ROOT_FILES.iter().map(|&s| s.to_string()).collect() } - fn get_workspace_documents(&mut self) -> &mut WorkspaceDocumentsHandler { - &mut self.workspace_documents + fn include_patterns(&self) -> Vec { + PYTHON_FILE_PATTERNS + .iter() + .map(|&s| s.to_string()) + .collect() + } + + fn exclude_patterns(&self) -> Vec { + common::utils::workspace_documents::DEFAULT_EXCLUDE_PATTERNS + .iter() + .map(|&s| s.to_string()) + .collect() } - fn get_pending_requests(&mut self) -> &mut PendingRequests { - &mut self.pending_requests + fn did_open_configuration(&self) -> common::utils::workspace_documents::DidOpenConfiguration { + common::utils::workspace_documents::DidOpenConfiguration::None } } -impl JediClient { - /// Create a new JediClient from an existing ProcessHandler - /// (for lsp-wrapper where we've already spawned the process) - pub fn new(process: ProcessHandler, root_path: String) -> Self { - let (tx, rx) = tokio::sync::broadcast::channel(1); +impl JediConfig { + pub fn new() -> Self { + Self + } - let workspace_documents = WorkspaceDocumentsHandler::new( + async fn find_workspace_folders( + &mut self, + root_path: String, + ) -> Result, Box> { + let mut workspace_folders: Vec = Vec::new(); + let include_patterns = self + .get_root_files() + .into_iter() + .map(|f| format!("**/{f}")) + .collect(); + let exclude_patterns = DEFAULT_EXCLUDE_PATTERNS + .iter() + .map(|&s| s.to_string()) + .collect(); + + match search_paths( Path::new(&root_path), - PYTHON_FILE_PATTERNS - .iter() - .map(|&s| s.to_string()) - .collect(), - DEFAULT_EXCLUDE_PATTERNS - .iter() - .map(|&s| s.to_string()) - .collect(), - rx, - DidOpenConfiguration::None, - ); - - let json_rpc_handler = JsonRpcHandler::new(); - - Self { - process, - json_rpc: json_rpc_handler, - workspace_documents, - pending_requests: PendingRequests::new(), + include_patterns, + exclude_patterns, + true, + FileType::Dir, + ) { + Ok(dirs) => { + for dir in dirs { + let folder_path = Path::new(&root_path).join(&dir); + if let Ok(uri) = Url::from_file_path(&folder_path) { + workspace_folders.push(WorkspaceFolder { + uri, + name: folder_path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("") + .to_string(), + }); + } + } + } + Err(e) => return Err(Box::new(e)), } - } - - /// Create a new JediClient by spawning a new jedi-language-server process - /// (for base implementation with watch events) - pub async fn from_spawn( - root_path: &str, - watch_events_rx: Receiver, - ) -> Result> { - let process = Command::new("jedi-language-server") - .current_dir(root_path) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .map_err(|e| Box::new(e) as Box)?; - - let process_handler = ProcessHandler::new(process) - .await - .map_err(|e| format!("Failed to create ProcessHandler: {}", e))?; - let workspace_documents = WorkspaceDocumentsHandler::new( - Path::new(root_path), - PYTHON_FILE_PATTERNS - .iter() - .map(|&s| s.to_string()) - .collect(), - DEFAULT_EXCLUDE_PATTERNS - .iter() - .map(|&s| s.to_string()) - .collect(), - watch_events_rx, - DidOpenConfiguration::None, - ); + if workspace_folders.is_empty() { + warn!("No workspace folders found. Using root path as workspace."); + if let Ok(uri) = Url::from_file_path(&root_path) { + workspace_folders.push(WorkspaceFolder { + uri, + name: root_path.to_string(), + }); + } + } - let json_rpc_handler = JsonRpcHandler::new(); + Ok(workspace_folders.into_iter().collect()) + } +} - Ok(Self { - process: process_handler, - json_rpc: json_rpc_handler, - workspace_documents, - pending_requests: PendingRequests::new(), - }) +impl Default for JediConfig { + fn default() -> Self { + Self::new() } } diff --git a/crates/wrapper/src/lsp/languages/sorbet.rs b/crates/wrapper/src/lsp/languages/sorbet.rs index 2fa5553d..ca2e08a0 100644 --- a/crates/wrapper/src/lsp/languages/sorbet.rs +++ b/crates/wrapper/src/lsp/languages/sorbet.rs @@ -1,54 +1,20 @@ -use std::fs; -use std::path::PathBuf; - -use crate::lsp::client::CLIENT_CAPABILITES; -use crate::lsp::{JsonRpcHandler, LspClient, PendingRequests, ProcessHandler}; +use crate::lsp::client::{LspConfig, CLIENT_CAPABILITES}; use async_trait::async_trait; -use common::api_types::JsonRpcMessage; +use common::utils::workspace_documents::{DidOpenConfiguration, RUBY_FILE_PATTERNS}; use log::{info, warn}; use lsp_types::{InitializeParams, Url, WorkspaceFolder}; use std::error::Error; +use std::fs; +use std::path::PathBuf; use tokio::process::Command; const DEFAULT_RBENV_ROOT: &str = "/opt/rbenv"; -pub struct SorbetClient { - process: ProcessHandler, - json_rpc: JsonRpcHandler, - workspace_documents: common::utils::workspace_documents::WorkspaceDocumentsHandler, - pending_requests: PendingRequests, - unexpected_notifications_tx: tokio::sync::broadcast::Sender, -} +pub struct SorbetConfig; #[async_trait] -impl LspClient for SorbetClient { - fn get_process(&mut self) -> &mut ProcessHandler { - &mut self.process - } - - fn get_json_rpc(&mut self) -> &mut JsonRpcHandler { - &mut self.json_rpc - } - - fn get_root_files(&mut self) -> Vec { - vec![] // Sorbet doesn't use root files in the new architecture - } - - fn get_workspace_documents( - &mut self, - ) -> &mut common::utils::workspace_documents::WorkspaceDocumentsHandler { - &mut self.workspace_documents - } - - fn get_pending_requests(&mut self) -> &mut PendingRequests { - &mut self.pending_requests - } - - fn get_unexpected_notifications_tx(&self) -> tokio::sync::broadcast::Sender { - self.unexpected_notifications_tx.clone() - } - +impl LspConfig for SorbetConfig { #[allow(deprecated)] async fn get_initialize_params( &mut self, @@ -56,7 +22,6 @@ impl LspClient for SorbetClient { ) -> Result> { let workspace_folders = self.find_workspace_folders(root_path.clone()).await?; - // Sorbet initialization options for lazy indexing let init_options = serde_json::json!({ "sorbet.lsp.lazyIndexing": true }); @@ -70,17 +35,41 @@ impl LspClient for SorbetClient { }) } + fn get_root_files(&mut self) -> Vec { + vec![] + } + + fn include_patterns(&self) -> Vec { + RUBY_FILE_PATTERNS.iter().map(|&s| s.to_string()).collect() + } + + fn exclude_patterns(&self) -> Vec { + common::utils::workspace_documents::DEFAULT_EXCLUDE_PATTERNS + .iter() + .map(|&s| s.to_string()) + .collect() + } + + fn did_open_configuration(&self) -> DidOpenConfiguration { + DidOpenConfiguration::Lazy + } +} + +impl SorbetConfig { + pub fn new() -> Self { + Self + } + async fn find_workspace_folders( &mut self, root_path: String, ) -> Result, Box> { info!( - "SorbetClient::find_workspace_folders called with root_path: {}", + "SorbetConfig::find_workspace_folders called with root_path: {}", root_path ); let root = PathBuf::from(&root_path); - // 1) Look for sorbet/config file let sorbet_config_path = root.join("sorbet").join("config"); info!( "Looking for sorbet/config at {:?}, exists: {}", @@ -90,7 +79,6 @@ impl LspClient for SorbetClient { if sorbet_config_path.exists() { info!("Found sorbet/config at {:?}", sorbet_config_path); - // Parse the config file to find --dir entries match fs::read_to_string(&sorbet_config_path) { Ok(contents) => { let mut workspace_folders = Vec::new(); @@ -99,9 +87,7 @@ impl LspClient for SorbetClient { while let Some(line) = lines.next() { let trimmed = line.trim(); - // Look for --dir option if trimmed == "--dir" { - // The directory path should be on the next line if let Some(dir_line) = lines.next() { let dir_path = dir_line.trim(); let full_path = root.join(dir_path); @@ -146,7 +132,6 @@ impl LspClient for SorbetClient { } } - // 2) Fallback: use the provided root_path warn!( "No sorbet/config found or no valid directories specified. Falling back to root: {}", root.display() @@ -164,28 +149,13 @@ impl LspClient for SorbetClient { } } -impl SorbetClient { - /// Create a new SorbetClient from the existing GenericLspClient components - pub fn new( - process: ProcessHandler, - json_rpc: JsonRpcHandler, - workspace_documents: common::utils::workspace_documents::WorkspaceDocumentsHandler, - pending_requests: PendingRequests, - ) -> Self { - let (unexpected_notifications_tx, _) = tokio::sync::broadcast::channel(1); - Self { - process, - json_rpc, - workspace_documents, - pending_requests, - unexpected_notifications_tx, - } +impl Default for SorbetConfig { + fn default() -> Self { + Self::new() } } -/// Parse Sorbet version from Gemfile.lock (preferred) or Gemfile fn parse_sorbet_version(workspace_path: &str) -> Option { - // Prefer Gemfile.lock let lock_path = PathBuf::from(workspace_path).join("Gemfile.lock"); if let Ok(contents) = fs::read_to_string(&lock_path) { if let Some(ver) = parse_sorbet_version_from_lock(&contents) { @@ -193,7 +163,6 @@ fn parse_sorbet_version(workspace_path: &str) -> Option { } } - // Fallback to Gemfile let gemfile_path = PathBuf::from(workspace_path).join("Gemfile"); if let Ok(contents) = fs::read_to_string(&gemfile_path) { if let Some(ver) = parse_sorbet_version_from_gemfile(&contents) { @@ -205,7 +174,6 @@ fn parse_sorbet_version(workspace_path: &str) -> Option { } fn parse_sorbet_version_from_lock(lock_contents: &str) -> Option { - // Look for lines like " sorbet (0.5.12414)" for line in lock_contents.lines() { let trimmed = line.trim(); if let Some(rest) = trimmed.strip_prefix("sorbet (") { @@ -221,14 +189,11 @@ fn parse_sorbet_version_from_lock(lock_contents: &str) -> Option { } fn parse_sorbet_version_from_gemfile(gemfile_contents: &str) -> Option { - // Look for lines like: gem "sorbet", "0.5.12414" for line in gemfile_contents.lines() { let trimmed = line.trim(); if trimmed.starts_with("gem") && trimmed.contains("sorbet") { - // Support double or single quotes let quote = if trimmed.contains('"') { '"' } else { '\'' }; let parts: Vec<&str> = trimmed.split(quote).collect(); - // parts at odd indices are quoted values if parts.len() >= 4 && parts[1].contains("sorbet") { let ver = parts[3].trim(); if !ver.is_empty() { @@ -301,7 +266,6 @@ async fn install_sorbet(version: &str) -> Result<(), String> { } } - // Refresh shims let rehash_status = command_with_rbenv_env(&rbenv_bin) .arg("rehash") .status() @@ -315,8 +279,6 @@ async fn install_sorbet(version: &str) -> Result<(), String> { Ok(()) } -/// Ensure the Sorbet gem version requested by the project is available and return it. -/// Returns Ok(Some(version)) when detected, Ok(None) when unspecified. pub async fn ensure_sorbet_version(workspace_path: &str) -> Result, String> { let Some(desired_version) = parse_sorbet_version(workspace_path) else { info!("No Sorbet version specified in Gemfile.lock or Gemfile; using preinstalled version"); diff --git a/crates/wrapper/src/lsp/mod.rs b/crates/wrapper/src/lsp/mod.rs index ce757e92..3f3dc7b7 100644 --- a/crates/wrapper/src/lsp/mod.rs +++ b/crates/wrapper/src/lsp/mod.rs @@ -5,6 +5,5 @@ pub mod languages; pub mod process; // Re-export commonly used types -pub use client::LspClient; pub use json_rpc::{ExpectedMessageKey, JsonRpcHandler, PendingRequests}; pub use process::ProcessHandler; diff --git a/crates/wrapper/src/main.rs b/crates/wrapper/src/main.rs index 5fd6aeb6..8073c872 100644 --- a/crates/wrapper/src/main.rs +++ b/crates/wrapper/src/main.rs @@ -9,16 +9,17 @@ mod handlers; mod lsp; mod managers; -use common::utils::workspace_documents::{ - DidOpenConfiguration, CSHARP_FILE_PATTERNS, C_AND_CPP_FILE_PATTERNS, GOLANG_FILE_PATTERNS, - JAVA_FILE_PATTERNS, PHP_FILE_PATTERNS, PYTHON_FILE_PATTERNS, RUBY_FILE_PATTERNS, - RUST_FILE_PATTERNS, TYPESCRIPT_AND_JAVASCRIPT_FILE_PATTERNS, -}; use lsp::client::LspClient; -use lsp::languages::{GenericLspClient, GoplsClient, SorbetClient}; -use lsp::process::ProcessHandler; +use lsp::languages::{GoplsConfig, SorbetConfig}; +use lsp::languages::{ + CSHARP_CONFIG, C_AND_CPP_CONFIG, JAVA_CONFIG, PHP_CONFIG, PYTHON_CONFIG, RUBY_CONFIG, + RUST_CONFIG, TYPESCRIPT_AND_JAVASCRIPT_CONFIG, +}; +use lsp::ProcessHandler; use managers::api::ApiManager; +use crate::lsp::client::LspConfig; + /// HTTP wrapper for LSP servers /// Provides HTTP endpoints for LSP JSON-RPC communication #[derive(Parser, Debug)] @@ -159,89 +160,52 @@ async fn main() -> std::io::Result<()> { std::io::Error::new(std::io::ErrorKind::Other, e) })?; - // Configure based on language - let (file_patterns, did_open_config) = match language.as_str() { - "php" => (PHP_FILE_PATTERNS.to_vec(), DidOpenConfiguration::Lazy), - "python" => (PYTHON_FILE_PATTERNS.to_vec(), DidOpenConfiguration::None), - "ruby" => (RUBY_FILE_PATTERNS.to_vec(), DidOpenConfiguration::None), - "ruby-sorbet" => { - info!("Detected ruby-sorbet language - using Lazy didOpen configuration"); - (RUBY_FILE_PATTERNS.to_vec(), DidOpenConfiguration::Lazy) - } - "typescript" | "javascript" => ( - TYPESCRIPT_AND_JAVASCRIPT_FILE_PATTERNS.to_vec(), - DidOpenConfiguration::Lazy, - ), - "rust" => (RUST_FILE_PATTERNS.to_vec(), DidOpenConfiguration::None), - "go" => (GOLANG_FILE_PATTERNS.to_vec(), DidOpenConfiguration::None), - "java" => (JAVA_FILE_PATTERNS.to_vec(), DidOpenConfiguration::None), - "cpp" | "c" => (C_AND_CPP_FILE_PATTERNS.to_vec(), DidOpenConfiguration::Lazy), - "csharp" => (CSHARP_FILE_PATTERNS.to_vec(), DidOpenConfiguration::None), - _ => { - error!("Unknown language '{}'. Supported languages: php, python, ruby, ruby-sorbet, typescript, javascript, rust, go, java, cpp, c, csharp", language); - return Err(std::io::Error::new( - std::io::ErrorKind::Other, - format!("Unsupported language: {}", language), - )); - } - }; - - // Create base client - let base_client = GenericLspClient::new( - process_handler, - args.workspace_path.clone(), - file_patterns.iter().map(|&s| s.to_string()).collect(), - did_open_config, - ); - - // Apply language-specific initialization and setup + // Apply language-specific configuration info!( "Checking LSP command for language-specific configuration: '{}'", args.lsp_command ); - let mut client: Box = match args.lsp_command.as_str() { + + let config: Box = match args.lsp_command.as_str() { "srb" => { info!("Configuring Sorbet with custom workspace folder detection (sorbet/config)"); - // Convert GenericLspClient components to SorbetClient - let (process, json_rpc, workspace_documents, pending_requests) = - base_client.into_components(); - let sorbet_client = - SorbetClient::new(process, json_rpc, workspace_documents, pending_requests); - Box::new(sorbet_client) + Box::new(SorbetConfig::new()) } _ => match language.as_str() { - "go" => { - info!("Configuring Go with custom workspace folder detection (go.work/go.mod)"); - // Convert GenericLspClient components to GoplsClient - let (process, json_rpc, workspace_documents, pending_requests) = - base_client.into_components(); - let gopls_client = - GoplsClient::new(process, json_rpc, workspace_documents, pending_requests); - Box::new(gopls_client) + "php" => Box::new(PHP_CONFIG.clone()), + "python" => Box::new(PYTHON_CONFIG.clone()), + "ruby" => Box::new(RUBY_CONFIG.clone()), + "ruby-sorbet" => { + info!("Detected ruby-sorbet language - using Sorbet config"); + Box::new(SorbetConfig::new()) } + "typescript" | "javascript" => Box::new(TYPESCRIPT_AND_JAVASCRIPT_CONFIG.clone()), "rust" => { info!("Configuring Rust with initialization options and setup workspace"); - let configured_client = base_client - .with_initialization_options(serde_json::json!({ - "cargo": { - "sysroot": serde_json::Value::Null - } - })) - .with_setup_workspace_method("rust-analyzer/reloadWorkspace".to_string()); - Box::new(configured_client) + Box::new(RUST_CONFIG.clone()) + } + "go" => { + info!("Configuring Go with custom workspace folder detection (go.work/go.mod)"); + Box::new(GoplsConfig::new()) } + "java" => Box::new(JAVA_CONFIG.clone()), "cpp" | "c" => { info!("Configuring C/C++ with clangd initialization options"); - let configured_client = - base_client.with_initialization_options(serde_json::json!({ - "clangdFileStatus": true - })); - Box::new(configured_client) + Box::new(C_AND_CPP_CONFIG.clone()) + } + "csharp" => Box::new(CSHARP_CONFIG.clone()), + _ => { + error!("Unknown language '{}'. Supported languages: php, python, ruby, ruby-sorbet, typescript, javascript, rust, go, java, cpp, c, csharp", language); + return Err(std::io::Error::new( + std::io::ErrorKind::Other, + format!("Unsupported language: {}", language), + )); } - _ => Box::new(base_client), }, }; + let mut client = LspClient::new(config, process_handler, &args.workspace_path); + // Initialize the LSP server client .initialize(args.workspace_path.clone()) @@ -257,8 +221,7 @@ async fn main() -> std::io::Result<()> { info!("Java: waiting for ServiceReady notification (no timeout - caller controls overall timeout)..."); let mut notification_rx = client - .get_pending_requests() - .add_notification(ExpectedMessageKey { + .expect_notification(ExpectedMessageKey { method: "language/status".to_string(), params: serde_json::json!({ "type": "ServiceReady", diff --git a/crates/wrapper/src/managers/api.rs b/crates/wrapper/src/managers/api.rs index 9754fabd..5616d6a7 100644 --- a/crates/wrapper/src/managers/api.rs +++ b/crates/wrapper/src/managers/api.rs @@ -40,15 +40,15 @@ pub enum ApiManagerError { /// Threadsafe manager that maps API calls to invocations to the local LSP server. pub struct ApiManager { - // Box for polymorphism - supports any language client + // LspClient supports any language client // Mutex for interior mutability (LSP client needs &mut self) // Arc for shared ownership across actix-web handlers - client: Arc>>, + client: Arc>, ast_grep: AstGrepClient, } impl ApiManager { - pub fn new(client: Arc>>) -> Self { + pub fn new(client: Arc>) -> Self { Self { client, ast_grep: AstGrepClient::new_wrapper(), @@ -271,7 +271,7 @@ impl ApiManager { range: Option, ) -> Result { let full_path = get_mount_dir().join(file_path); - let mut locked_client = self.client.lock().await; + let locked_client = self.client.lock().await; locked_client .get_workspace_documents() .read_text_document(&full_path, range) @@ -287,7 +287,7 @@ impl ApiManager { } /// Get the underlying LSP client for WebSocket connections - pub fn get_lsp_client(&self) -> Arc>> { + pub fn get_lsp_client(&self) -> Arc> { self.client.clone() } diff --git a/crates/wrapper/src/managers/ws.rs b/crates/wrapper/src/managers/ws.rs index 993a36d3..221c4a29 100644 --- a/crates/wrapper/src/managers/ws.rs +++ b/crates/wrapper/src/managers/ws.rs @@ -8,11 +8,11 @@ use tokio::sync::Mutex; /// Manages WebSocket connections for bidirectional LSP communication pub struct WsManager { - client: Arc>>, + client: Arc>, } impl WsManager { - pub fn new(client: Arc>>) -> Self { + pub fn new(client: Arc>) -> Self { Self { client } } @@ -62,7 +62,7 @@ impl WsManager { } /// Spawn a task to forward LSP notifications to the WebSocket -fn spawn_notification_forwarder(client: Arc>>, mut session: Session) { +fn spawn_notification_forwarder(client: Arc>, mut session: Session) { tokio::spawn(async move { let mut notification_rx = { let locked_client = client.lock().await; @@ -97,7 +97,7 @@ fn spawn_notification_forwarder(client: Arc>>, mut sess /// Handle a text message from the WebSocket async fn handle_ws_text_message( text: String, - client: &Arc>>, + client: &Arc>, session: &Session, ) -> Result<(), Box> { debug!("Received WebSocket message: {}", text); @@ -122,7 +122,7 @@ async fn handle_ws_text_message( fn handle_lsp_request( json_rpc_msg: JsonRpcMessage, method: String, - client: Arc>>, + client: Arc>, mut session: Session, ) { tokio::spawn(async move { @@ -165,7 +165,7 @@ fn handle_lsp_request( async fn handle_lsp_notification( method: String, params: Option, - client: &Arc>>, + client: &Arc>, ) -> Result<(), Box> { let mut locked_client = client.lock().await; locked_client.send_notification(&method, params).await?; From 02cd706b2a63f3e721df8965a65eaa423cd75c85 Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Thu, 4 Dec 2025 13:43:33 +0100 Subject: [PATCH 55/92] Improve debug output --- crates/wrapper/src/lsp/client.rs | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/crates/wrapper/src/lsp/client.rs b/crates/wrapper/src/lsp/client.rs index 2c431cdf..775a114b 100644 --- a/crates/wrapper/src/lsp/client.rs +++ b/crates/wrapper/src/lsp/client.rs @@ -186,9 +186,10 @@ impl LspClient { if let Ok(message) = json_rpc.parse_message(&raw_response) { if let Some(id) = &message.id { // we always use u64 ids here, so the server process should respond with those as well - let id = id.as_u64().unwrap_or_else(|| { - panic!("process responded with invalid id type") - }); + let Some(id) = id.as_u64() else { + debug!("Message has invalid id type: {:?}", message.id); + continue; + }; debug!("Received response for request {}", id); if let Ok(Some(sender)) = pending_requests.remove_request(id).await { if sender.send(message.clone()).is_err() { @@ -199,7 +200,7 @@ impl LspClient { let _ = process.send(&response).await; } } else if let Some(method) = message.method.clone() { - debug!("Received notification"); + debug!("Received notification {}", method); let mut handled = false; if let Some(params) = &message.params { let message_key = ExpectedMessageKey { @@ -217,7 +218,7 @@ impl LspClient { let _ = notification_channel.send(message); } } else { - debug!("Received unexpected message"); + debug!("Received unexpected message: {:?}", message); } } } @@ -228,7 +229,7 @@ impl LspClient { } async fn send_initialized(&mut self) -> Result<(), Box> { - debug!("Sending 'initialized' notification"); + info!("Sending initialized"); self.send_notification("initialized", Some(serde_json::json!({}))) .await } @@ -352,6 +353,11 @@ impl LspClient { file_path: &str, position: Position, ) -> Result, Box> { + debug!( + "Requesting goto references for {}, line {}, character {}", + file_path, position.line, position.character + ); + // Get the configuration and check if document is opened first let needs_open = { let workspace_documents = &self.workspace_documents; From d1c5233b078c320c030456f7a014152996133883 Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Fri, 12 Dec 2025 15:16:54 +0100 Subject: [PATCH 56/92] Implement support for shared proxy containers --- clients/typescript/src/cli.ts | 32 ++++++++++++++++++++++++++++++-- clients/typescript/src/server.ts | 28 ++++++++++++++++++++++++++-- clients/typescript/src/types.ts | 3 +++ 3 files changed, 59 insertions(+), 4 deletions(-) diff --git a/clients/typescript/src/cli.ts b/clients/typescript/src/cli.ts index 79e9b98c..b04e100c 100644 --- a/clients/typescript/src/cli.ts +++ b/clients/typescript/src/cli.ts @@ -1,5 +1,5 @@ #!/usr/bin/env node -import { Command } from "commander"; +import { Command, Option } from "commander"; import { isErr } from "./types.js"; import type { BaseCommandOptions, @@ -56,6 +56,15 @@ async function generateRandomContainerName(): Promise { return `nuanced-lsp-${id}`; } +async function generateSharedContainerName(workspace: string): Promise { + const { createHash } = await import("node:crypto"); + const { resolve } = await import("node:path"); + const absolutePath = resolve(workspace); + const hash = createHash("sha256").update(absolutePath).digest("hex"); + const shortHash = hash.slice(0, 12); + return `nuanced-lsp-shared-${shortHash}`; +} + // Lazy-load client (avoids startup cost if user only runs --help, etc.) async function lspClient(opts: { containerName?: string; @@ -260,13 +269,24 @@ async function serverCommand( workspace: string, opts: ServerCommandOptions, ): Promise { - const containerName = await generateRandomContainerName(); + let containerName: string; + if (opts.shared !== undefined) { + containerName = await generateSharedContainerName(workspace); + } else { + containerName = await generateRandomContainerName(); + } + const client = await lspClient({ containerName, ...opts, lspPort: opts.hostPort, }); + if (opts.shared === "down") { + await client.down(); + return; + } + try { // Run the LSP server stdio loop const { runLspServer } = await import("./server.js"); @@ -717,6 +737,14 @@ program (v: string, prev: string[] | undefined) => (prev ? prev.concat(v) : [v]), ) .option("--env-file ", "Path to an env file") + .addOption( + new Option( + "--shared [mode]", + "Share container across multiple server instances", + ) + .choices(["up", "down", "use"]) + .preset("use"), + ) .action(serverCommand); program diff --git a/clients/typescript/src/server.ts b/clients/typescript/src/server.ts index 47d80f52..42b0dca7 100644 --- a/clients/typescript/src/server.ts +++ b/clients/typescript/src/server.ts @@ -48,6 +48,10 @@ class LspServer { async run(): Promise { try { await this.startServer(); + if (this.opts.shared === "up") { + await this.sendLogMessage(MessageType.Info, "Nuanced LSP started"); + return; + } this.setupSignalHandlers(); await this.sendLogMessage(MessageType.Info, "Nuanced LSP started"); await this.processMessages(); @@ -59,11 +63,29 @@ class LspServer { throw err; } finally { await this.stopServer(); - await this.sendLogMessage(MessageType.Info, "Nuanced LSP stopped"); + if (!this.opts.shared) { + await this.sendLogMessage(MessageType.Info, "Nuanced LSP stopped"); + } else { + await this.sendLogMessage( + MessageType.Info, + "Nuanced LSP disconnected (shared container left running)", + ); + } } } private async startServer(): Promise { + if (this.opts.shared) { + const statusRes = await this.client.status(); + if (statusRes.ok) { + await this.sendLogMessage( + MessageType.Info, + `Existing shared container '${this.client.containerName}'`, + ); + return; + } + } + const res = await this.client.up(this.workspace, { proxyImage: this.opts.proxyImage, watchdogImage: this.opts.watchdogImage, @@ -178,7 +200,9 @@ class LspServer { this.ws = null; } - await this.client.down(); + if (!this.opts.shared) { + await this.client.down(); + } } private async processMessages(): Promise { diff --git a/clients/typescript/src/types.ts b/clients/typescript/src/types.ts index ba4f6615..bbc55958 100644 --- a/clients/typescript/src/types.ts +++ b/clients/typescript/src/types.ts @@ -481,6 +481,9 @@ export const ServerCommandOptionsSchema = BaseCommandOptionsSchema.extend({ debug: z.boolean().optional(), env: z.array(z.string()).optional(), envFile: z.string().optional(), + shared: z + .union([z.literal("up"), z.literal("down"), z.literal("use")]) + .optional(), }); export type ServerCommandOptions = Named< typeof ServerCommandOptionsSchema, From 9a424b6fdf40ad70b71ffc57b140db9eac85a095 Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Fri, 12 Dec 2025 16:15:25 +0100 Subject: [PATCH 57/92] Make VSCode plugin more flexible for experimentation --- clients/typescript/src/server.ts | 3 ++- clients/vscode/package.json | 32 ++++++++--------------- clients/vscode/src/extension.ts | 44 ++++++++++++++++++++------------ 3 files changed, 39 insertions(+), 40 deletions(-) diff --git a/clients/typescript/src/server.ts b/clients/typescript/src/server.ts index 42b0dca7..a7a8105d 100644 --- a/clients/typescript/src/server.ts +++ b/clients/typescript/src/server.ts @@ -71,6 +71,7 @@ class LspServer { "Nuanced LSP disconnected (shared container left running)", ); } + process.exit(0); } } @@ -80,7 +81,7 @@ class LspServer { if (statusRes.ok) { await this.sendLogMessage( MessageType.Info, - `Existing shared container '${this.client.containerName}'`, + `Using existing shared container '${this.client.containerName}'`, ); return; } diff --git a/clients/vscode/package.json b/clients/vscode/package.json index 23c4e5ad..9fd5aa08 100644 --- a/clients/vscode/package.json +++ b/clients/vscode/package.json @@ -35,29 +35,17 @@ "title": "Nuanced LSP", "properties": { "nuancedLsp.command": { - "type": "string", - "default": "nuanced-lsp", - "description": "Command to run the Nuanced LSP server" + "anyOf": [ + { "type": "string" }, + { "type": "array", "items": { "type": "string" } } + ], + "default": ["nuanced-lsp", "server"], + "description": "Command to run the Nuanced LSP server. Can be a string (command only) or an array where the first element is the command and the rest are arguments. Default: [\"nuanced-lsp\", \"server\"]" }, - "nuancedLsp.proxyImage": { - "type": "string", - "default": "", - "description": "The LSP proxy image to use (optional)" - }, - "nuancedLsp.wrapperImage": { - "type": "string", - "default": "", - "description": "The LSP wrapper image to use (optional)" - }, - "nuancedLsp.watchdogImage": { - "type": "string", - "default": "", - "description": "The LSP watchdog image to use (optional)" - }, - "nuancedLsp.debug": { - "type": "boolean", - "default": false, - "description": "Enable debug mode for the Nuanced LSP server" + "nuancedLsp.env": { + "type": "object", + "default": {}, + "description": "Environment variables to pass to the LSP server command" } } } diff --git a/clients/vscode/src/extension.ts b/clients/vscode/src/extension.ts index c07b028f..91c4a4a6 100644 --- a/clients/vscode/src/extension.ts +++ b/clients/vscode/src/extension.ts @@ -9,11 +9,11 @@ let client: LanguageClient | undefined; function startClient() { const config = vscode.workspace.getConfiguration("nuancedLsp"); - const command = config.get("command", "nuanced-lsp"); - const proxyImage = config.get("proxyImage", ""); - const watchdogImage = config.get("watchdogImage", ""); - const wrapperImage = config.get("wrapperImage", ""); - const debug = config.get("debug", false); + const commandConfig = config.get("command", [ + "nuanced-lsp", + "server", + ]); + const env = config.get>("env", {}); const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; if (!workspaceFolder) { @@ -25,24 +25,34 @@ function startClient() { const workspacePath = workspaceFolder.uri.fsPath; - const args = ["server", "--host-port", "0"]; - if (proxyImage) { - args.push("--proxy-image", proxyImage); - } - if (watchdogImage) { - args.push("--watchdog-image", watchdogImage); - } - if (wrapperImage) { - args.push("--wrapper-image", wrapperImage); - } - if (debug) { - args.push("--debug"); + let command: string; + let args: string[]; + + if (Array.isArray(commandConfig)) { + if (commandConfig.length === 0) { + command = "nuanced-lsp"; + args = ["server"]; + } else { + command = commandConfig[0]; + args = commandConfig.slice(1); + } + } else if (commandConfig === "") { + command = "nuanced-lsp"; + args = ["server"]; + } else { + command = commandConfig; + args = ["server"]; } + + args.push("--host-port", "0"); args.push(workspacePath); const serverOptions: ServerOptions = { command, args, + options: { + env: { ...process.env, ...env }, + }, }; const clientOptions: LanguageClientOptions = { From 70b1b6ebdd25738e60466a4e9481b44c8ca49d8c Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Fri, 12 Dec 2025 17:44:38 +0100 Subject: [PATCH 58/92] Update shared mode flags --- clients/typescript/src/cli.ts | 18 +++++++++--------- clients/typescript/src/server.ts | 2 +- clients/typescript/src/types.ts | 3 ++- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/clients/typescript/src/cli.ts b/clients/typescript/src/cli.ts index b04e100c..dfdfa335 100644 --- a/clients/typescript/src/cli.ts +++ b/clients/typescript/src/cli.ts @@ -62,7 +62,7 @@ async function generateSharedContainerName(workspace: string): Promise { const absolutePath = resolve(workspace); const hash = createHash("sha256").update(absolutePath).digest("hex"); const shortHash = hash.slice(0, 12); - return `nuanced-lsp-shared-${shortHash}`; + return `nuanced-lsp-${shortHash}`; } // Lazy-load client (avoids startup cost if user only runs --help, etc.) @@ -270,7 +270,7 @@ async function serverCommand( opts: ServerCommandOptions, ): Promise { let containerName: string; - if (opts.shared !== undefined) { + if (opts.shared) { containerName = await generateSharedContainerName(workspace); } else { containerName = await generateRandomContainerName(); @@ -282,7 +282,7 @@ async function serverCommand( lspPort: opts.hostPort, }); - if (opts.shared === "down") { + if (opts.shared && opts.sharedMode === "down") { await client.down(); return; } @@ -737,13 +737,13 @@ program (v: string, prev: string[] | undefined) => (prev ? prev.concat(v) : [v]), ) .option("--env-file ", "Path to an env file") + .option("--shared", "Share container across multiple server instances") .addOption( - new Option( - "--shared [mode]", - "Share container across multiple server instances", - ) - .choices(["up", "down", "use"]) - .preset("use"), + new Option("--shared-mode ", "Mode for shared container").choices([ + "up", + "down", + "use", + ]), ) .action(serverCommand); diff --git a/clients/typescript/src/server.ts b/clients/typescript/src/server.ts index a7a8105d..1b3f7011 100644 --- a/clients/typescript/src/server.ts +++ b/clients/typescript/src/server.ts @@ -48,7 +48,7 @@ class LspServer { async run(): Promise { try { await this.startServer(); - if (this.opts.shared === "up") { + if (this.opts.shared && this.opts.sharedMode === "up") { await this.sendLogMessage(MessageType.Info, "Nuanced LSP started"); return; } diff --git a/clients/typescript/src/types.ts b/clients/typescript/src/types.ts index bbc55958..027ef75f 100644 --- a/clients/typescript/src/types.ts +++ b/clients/typescript/src/types.ts @@ -481,7 +481,8 @@ export const ServerCommandOptionsSchema = BaseCommandOptionsSchema.extend({ debug: z.boolean().optional(), env: z.array(z.string()).optional(), envFile: z.string().optional(), - shared: z + shared: z.boolean().optional(), + sharedMode: z .union([z.literal("up"), z.literal("down"), z.literal("use")]) .optional(), }); From 528b0adaf174c92b2eefff12e10af168dc331222 Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Mon, 15 Dec 2025 14:44:37 +0100 Subject: [PATCH 59/92] Remove dist from repo --- clients/vscode/.gitignore | 2 + clients/vscode/dist/extension.d.ts | 4 - clients/vscode/dist/extension.d.ts.map | 1 - clients/vscode/dist/extension.js | 116 ------------------------- clients/vscode/dist/extension.js.map | 1 - 5 files changed, 2 insertions(+), 122 deletions(-) create mode 100644 clients/vscode/.gitignore delete mode 100644 clients/vscode/dist/extension.d.ts delete mode 100644 clients/vscode/dist/extension.d.ts.map delete mode 100644 clients/vscode/dist/extension.js delete mode 100644 clients/vscode/dist/extension.js.map diff --git a/clients/vscode/.gitignore b/clients/vscode/.gitignore new file mode 100644 index 00000000..3d2bc626 --- /dev/null +++ b/clients/vscode/.gitignore @@ -0,0 +1,2 @@ +/dist/ +/node_modules/ diff --git a/clients/vscode/dist/extension.d.ts b/clients/vscode/dist/extension.d.ts deleted file mode 100644 index 30af23db..00000000 --- a/clients/vscode/dist/extension.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -import * as vscode from "vscode"; -export declare function activate(context: vscode.ExtensionContext): void; -export declare function deactivate(): Thenable | undefined; -//# sourceMappingURL=extension.d.ts.map \ No newline at end of file diff --git a/clients/vscode/dist/extension.d.ts.map b/clients/vscode/dist/extension.d.ts.map deleted file mode 100644 index 9287bdcd..00000000 --- a/clients/vscode/dist/extension.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"extension.d.ts","sourceRoot":"","sources":["../src/extension.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,MAAM,QAAQ,CAAC;AAmFjC,wBAAgB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC,gBAAgB,QAcxD;AAED,wBAAgB,UAAU,IAAI,QAAQ,CAAC,IAAI,CAAC,GAAG,SAAS,CAKvD"} \ No newline at end of file diff --git a/clients/vscode/dist/extension.js b/clients/vscode/dist/extension.js deleted file mode 100644 index 70f4cfbe..00000000 --- a/clients/vscode/dist/extension.js +++ /dev/null @@ -1,116 +0,0 @@ -"use strict"; -var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { - if (k2 === undefined) k2 = k; - var desc = Object.getOwnPropertyDescriptor(m, k); - if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { - desc = { enumerable: true, get: function() { return m[k]; } }; - } - Object.defineProperty(o, k2, desc); -}) : (function(o, m, k, k2) { - if (k2 === undefined) k2 = k; - o[k2] = m[k]; -})); -var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { - Object.defineProperty(o, "default", { enumerable: true, value: v }); -}) : function(o, v) { - o["default"] = v; -}); -var __importStar = (this && this.__importStar) || (function () { - var ownKeys = function(o) { - ownKeys = Object.getOwnPropertyNames || function (o) { - var ar = []; - for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; - return ar; - }; - return ownKeys(o); - }; - return function (mod) { - if (mod && mod.__esModule) return mod; - var result = {}; - if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); - __setModuleDefault(result, mod); - return result; - }; -})(); -Object.defineProperty(exports, "__esModule", { value: true }); -exports.activate = activate; -exports.deactivate = deactivate; -const vscode = __importStar(require("vscode")); -const node_1 = require("vscode-languageclient/node"); -let client; -function startClient() { - const config = vscode.workspace.getConfiguration("nuancedLsp"); - const command = config.get("command", "nuanced-lsp"); - const proxyImage = config.get("proxyImage", ""); - const watchdogImage = config.get("watchdogImage", ""); - const wrapperImage = config.get("wrapperImage", ""); - const debug = config.get("debug", false); - const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; - if (!workspaceFolder) { - vscode.window.showErrorMessage("Nuanced LSP: No workspace folder found. Please open a folder to use this extension."); - return; - } - const workspacePath = workspaceFolder.uri.fsPath; - const args = ["server", "--host-port", "0"]; - if (proxyImage) { - args.push("--proxy-image", proxyImage); - } - if (watchdogImage) { - args.push("--watchdog-image", watchdogImage); - } - if (wrapperImage) { - args.push("--wrapper-image", wrapperImage); - } - if (debug) { - args.push("--debug"); - } - args.push(workspacePath); - const serverOptions = { - command, - args, - }; - const clientOptions = { - documentSelector: [ - { scheme: "file", language: "python" }, - { scheme: "file", language: "typescript" }, - { scheme: "file", language: "javascript" }, - { scheme: "file", language: "rust" }, - { scheme: "file", language: "cpp" }, - { scheme: "file", language: "csharp" }, - { scheme: "file", language: "java" }, - { scheme: "file", language: "go" }, - { scheme: "file", language: "php" }, - { scheme: "file", language: "ruby" }, - ], - synchronize: { - fileEvents: vscode.workspace.createFileSystemWatcher("**/*"), - }, - }; - client = new node_1.LanguageClient("nuancedLsp", "Nuanced LSP", serverOptions, clientOptions); - client.start(); -} -async function restartClient() { - if (client) { - await client.stop(); - } - startClient(); - vscode.window.showInformationMessage("Nuanced LSP: Server restarted"); -} -function activate(context) { - startClient(); - context.subscriptions.push(vscode.commands.registerCommand("nuancedLsp.restart", restartClient)); - context.subscriptions.push({ - dispose: () => { - if (client) { - client.stop(); - } - }, - }); -} -function deactivate() { - if (!client) { - return undefined; - } - return client.stop(); -} -//# sourceMappingURL=extension.js.map \ No newline at end of file diff --git a/clients/vscode/dist/extension.js.map b/clients/vscode/dist/extension.js.map deleted file mode 100644 index fa58cade..00000000 --- a/clients/vscode/dist/extension.js.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"extension.js","sourceRoot":"","sources":["../src/extension.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAmFA,4BAcC;AAED,gCAKC;AAxGD,+CAAiC;AACjC,qDAIoC;AAEpC,IAAI,MAAkC,CAAC;AAEvC,SAAS,WAAW;IAClB,MAAM,MAAM,GAAG,MAAM,CAAC,SAAS,CAAC,gBAAgB,CAAC,YAAY,CAAC,CAAC;IAC/D,MAAM,OAAO,GAAG,MAAM,CAAC,GAAG,CAAS,SAAS,EAAE,aAAa,CAAC,CAAC;IAC7D,MAAM,UAAU,GAAG,MAAM,CAAC,GAAG,CAAS,YAAY,EAAE,EAAE,CAAC,CAAC;IACxD,MAAM,aAAa,GAAG,MAAM,CAAC,GAAG,CAAS,eAAe,EAAE,EAAE,CAAC,CAAC;IAC9D,MAAM,YAAY,GAAG,MAAM,CAAC,GAAG,CAAS,cAAc,EAAE,EAAE,CAAC,CAAC;IAC5D,MAAM,KAAK,GAAG,MAAM,CAAC,GAAG,CAAU,OAAO,EAAE,KAAK,CAAC,CAAC;IAElD,MAAM,eAAe,GAAG,MAAM,CAAC,SAAS,CAAC,gBAAgB,EAAE,CAAC,CAAC,CAAC,CAAC;IAC/D,IAAI,CAAC,eAAe,EAAE,CAAC;QACrB,MAAM,CAAC,MAAM,CAAC,gBAAgB,CAC5B,qFAAqF,CACtF,CAAC;QACF,OAAO;IACT,CAAC;IAED,MAAM,aAAa,GAAG,eAAe,CAAC,GAAG,CAAC,MAAM,CAAC;IAEjD,MAAM,IAAI,GAAG,CAAC,QAAQ,EAAE,aAAa,EAAE,GAAG,CAAC,CAAC;IAC5C,IAAI,UAAU,EAAE,CAAC;QACf,IAAI,CAAC,IAAI,CAAC,eAAe,EAAE,UAAU,CAAC,CAAC;IACzC,CAAC;IACD,IAAI,aAAa,EAAE,CAAC;QAClB,IAAI,CAAC,IAAI,CAAC,kBAAkB,EAAE,aAAa,CAAC,CAAC;IAC/C,CAAC;IACD,IAAI,YAAY,EAAE,CAAC;QACjB,IAAI,CAAC,IAAI,CAAC,iBAAiB,EAAE,YAAY,CAAC,CAAC;IAC7C,CAAC;IACD,IAAI,KAAK,EAAE,CAAC;QACV,IAAI,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC;IACvB,CAAC;IACD,IAAI,CAAC,IAAI,CAAC,aAAa,CAAC,CAAC;IAEzB,MAAM,aAAa,GAAkB;QACnC,OAAO;QACP,IAAI;KACL,CAAC;IAEF,MAAM,aAAa,GAA0B;QAC3C,gBAAgB,EAAE;YAChB,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,QAAQ,EAAE;YACtC,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,YAAY,EAAE;YAC1C,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,YAAY,EAAE;YAC1C,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE;YACpC,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,KAAK,EAAE;YACnC,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,QAAQ,EAAE;YACtC,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE;YACpC,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE;YAClC,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,KAAK,EAAE;YACnC,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE;SACrC;QACD,WAAW,EAAE;YACX,UAAU,EAAE,MAAM,CAAC,SAAS,CAAC,uBAAuB,CAAC,MAAM,CAAC;SAC7D;KACF,CAAC;IAEF,MAAM,GAAG,IAAI,qBAAc,CACzB,YAAY,EACZ,aAAa,EACb,aAAa,EACb,aAAa,CACd,CAAC;IAEF,MAAM,CAAC,KAAK,EAAE,CAAC;AACjB,CAAC;AAED,KAAK,UAAU,aAAa;IAC1B,IAAI,MAAM,EAAE,CAAC;QACX,MAAM,MAAM,CAAC,IAAI,EAAE,CAAC;IACtB,CAAC;IACD,WAAW,EAAE,CAAC;IACd,MAAM,CAAC,MAAM,CAAC,sBAAsB,CAAC,+BAA+B,CAAC,CAAC;AACxE,CAAC;AAED,SAAgB,QAAQ,CAAC,OAAgC;IACvD,WAAW,EAAE,CAAC;IAEd,OAAO,CAAC,aAAa,CAAC,IAAI,CACxB,MAAM,CAAC,QAAQ,CAAC,eAAe,CAAC,oBAAoB,EAAE,aAAa,CAAC,CACrE,CAAC;IAEF,OAAO,CAAC,aAAa,CAAC,IAAI,CAAC;QACzB,OAAO,EAAE,GAAG,EAAE;YACZ,IAAI,MAAM,EAAE,CAAC;gBACX,MAAM,CAAC,IAAI,EAAE,CAAC;YAChB,CAAC;QACH,CAAC;KACF,CAAC,CAAC;AACL,CAAC;AAED,SAAgB,UAAU;IACxB,IAAI,CAAC,MAAM,EAAE,CAAC;QACZ,OAAO,SAAS,CAAC;IACnB,CAAC;IACD,OAAO,MAAM,CAAC,IAAI,EAAE,CAAC;AACvB,CAAC"} \ No newline at end of file From 01d4cb4297507f9db3bfe45021f80ec3a9e071dc Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Mon, 15 Dec 2025 18:01:49 +0100 Subject: [PATCH 60/92] Fix (re)connection logic --- clients/typescript/src/server.ts | 59 ++++++++++++++++++-------------- clients/vscode/src/extension.ts | 24 ++++--------- 2 files changed, 40 insertions(+), 43 deletions(-) diff --git a/clients/typescript/src/server.ts b/clients/typescript/src/server.ts index 1b3f7011..3d1d3b69 100644 --- a/clients/typescript/src/server.ts +++ b/clients/typescript/src/server.ts @@ -76,6 +76,8 @@ class LspServer { } private async startServer(): Promise { + let mustStartServer = true; + if (this.opts.shared) { const statusRes = await this.client.status(); if (statusRes.ok) { @@ -83,30 +85,32 @@ class LspServer { MessageType.Info, `Using existing shared container '${this.client.containerName}'`, ); - return; + mustStartServer = false; } } - const res = await this.client.up(this.workspace, { - proxyImage: this.opts.proxyImage, - watchdogImage: this.opts.watchdogImage, - wrapperImage: this.opts.wrapperImage, - languageContainerVersion: this.opts.languageContainerVersion, - timeout: this.opts.timeout, - stream: false, - ro: this.opts.ro, - bindHost: this.opts.bindHost, - debug: this.opts.debug, - env: this.opts.env, - envFile: this.opts.envFile, - }); + if (mustStartServer) { + const res = await this.client.up(this.workspace, { + proxyImage: this.opts.proxyImage, + watchdogImage: this.opts.watchdogImage, + wrapperImage: this.opts.wrapperImage, + languageContainerVersion: this.opts.languageContainerVersion, + timeout: this.opts.timeout, + stream: false, + ro: this.opts.ro, + bindHost: this.opts.bindHost, + debug: this.opts.debug, + env: this.opts.env, + envFile: this.opts.envFile, + }); - if (isErr(res)) { - await this.sendLogMessage( - MessageType.Error, - `Failed to start LSP server container: ${JSON.stringify(res.data)}`, - ); - throw new Error("Failed to start container"); + if (isErr(res)) { + await this.sendLogMessage( + MessageType.Error, + `Failed to start LSP server container: ${JSON.stringify(res.data)}`, + ); + throw new Error("Failed to start container"); + } } await this.waitUntilServerIsHealthy(); @@ -159,13 +163,15 @@ class LspServer { this.ws.on("error", (err: Error) => { this.sendLogMessage( MessageType.Error, - `WebSocket error: ${err.message}`, + `WebSocket error: ${err.name}: ${err.message}`, ); - reject(err); }); - this.ws.on("close", () => { - this.sendLogMessage(MessageType.Debug, "WebSocket closed"); + this.ws.on("close", (ev: { reason: string }) => { + this.sendLogMessage( + MessageType.Debug, + `WebSocket closed: ${ev.reason}`, + ); }); this.ws.on("message", (data: Buffer | string) => { @@ -173,10 +179,11 @@ class LspServer { const message = JSON.parse(data.toString()) as JsonRpcMessage; // Forward server notifications to stdout writeMessage(this.output, message); - } catch (err) { + } catch (rawErr) { + const err = rawErr as Error; this.sendLogMessage( MessageType.Error, - `Failed to parse WebSocket message: ${err}`, + `Failed to parse WebSocket message: ${err.name}: ${err.message}`, ); } }); diff --git a/clients/vscode/src/extension.ts b/clients/vscode/src/extension.ts index 91c4a4a6..4b004983 100644 --- a/clients/vscode/src/extension.ts +++ b/clients/vscode/src/extension.ts @@ -5,14 +5,13 @@ import { ServerOptions, } from "vscode-languageclient/node"; +const DEFAULT_COMMAND_CONFIG = ["nuanced-lsp", "server", "--host-port", "0"]; + let client: LanguageClient | undefined; function startClient() { const config = vscode.workspace.getConfiguration("nuancedLsp"); - const commandConfig = config.get("command", [ - "nuanced-lsp", - "server", - ]); + const commandConfig = config.get("command"); const env = config.get>("env", {}); const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; @@ -29,22 +28,13 @@ function startClient() { let args: string[]; if (Array.isArray(commandConfig)) { - if (commandConfig.length === 0) { - command = "nuanced-lsp"; - args = ["server"]; - } else { - command = commandConfig[0]; - args = commandConfig.slice(1); - } - } else if (commandConfig === "") { - command = "nuanced-lsp"; - args = ["server"]; + [command, ...args] = commandConfig; + } else if (typeof commandConfig === "string") { + [command, ...args] = [commandConfig]; } else { - command = commandConfig; - args = ["server"]; + [command, ...args] = DEFAULT_COMMAND_CONFIG; } - args.push("--host-port", "0"); args.push(workspacePath); const serverOptions: ServerOptions = { From 72a1dfa10ef90deefe9d484fe82910794ba909af Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Mon, 15 Dec 2025 20:29:24 +0100 Subject: [PATCH 61/92] Fix config --- clients/vscode/package.json | 1 - clients/vscode/src/extension.ts | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/clients/vscode/package.json b/clients/vscode/package.json index 9fd5aa08..ee1f1524 100644 --- a/clients/vscode/package.json +++ b/clients/vscode/package.json @@ -39,7 +39,6 @@ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ], - "default": ["nuanced-lsp", "server"], "description": "Command to run the Nuanced LSP server. Can be a string (command only) or an array where the first element is the command and the rest are arguments. Default: [\"nuanced-lsp\", \"server\"]" }, "nuancedLsp.env": { diff --git a/clients/vscode/src/extension.ts b/clients/vscode/src/extension.ts index 4b004983..1f88e1d0 100644 --- a/clients/vscode/src/extension.ts +++ b/clients/vscode/src/extension.ts @@ -12,7 +12,7 @@ let client: LanguageClient | undefined; function startClient() { const config = vscode.workspace.getConfiguration("nuancedLsp"); const commandConfig = config.get("command"); - const env = config.get>("env", {}); + const envConfig = config.get>("env", {}); const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; if (!workspaceFolder) { @@ -41,7 +41,7 @@ function startClient() { command, args, options: { - env: { ...process.env, ...env }, + env: { ...process.env, ...envConfig }, }, }; From 555092a441931f654e57450d61f4b07b0958d1aa Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Mon, 19 Jan 2026 15:03:23 +0100 Subject: [PATCH 62/92] Reduce artifact retention --- .github/workflows/service-tests.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/service-tests.yml b/.github/workflows/service-tests.yml index 64f614f7..d9b40e6f 100644 --- a/.github/workflows/service-tests.yml +++ b/.github/workflows/service-tests.yml @@ -82,6 +82,7 @@ jobs: with: name: built-images path: build-artifacts/images.tar + retention-days: 1 test-rust: runs-on: ubuntu-latest From 77a88ca74da12cebd17602dfa8daaef352994ea5 Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Mon, 19 Jan 2026 17:22:54 +0100 Subject: [PATCH 63/92] Remove /lsp endpoint and simplify proxy creation a little --- crates/proxy/src/handlers/lsp.rs | 278 ---------------------------- crates/proxy/src/handlers/lsp_ws.rs | 137 +++++++++++++- crates/proxy/src/handlers/mod.rs | 3 +- crates/proxy/src/lib.rs | 91 +++------ crates/proxy/src/middleware/jwt.rs | 9 + crates/wrapper/src/handlers/lsp.rs | 50 ----- crates/wrapper/src/handlers/mod.rs | 1 - crates/wrapper/src/main.rs | 1 - scripts/test-all-endpoints.sh | 8 - 9 files changed, 165 insertions(+), 413 deletions(-) delete mode 100644 crates/proxy/src/handlers/lsp.rs delete mode 100644 crates/wrapper/src/handlers/lsp.rs diff --git a/crates/proxy/src/handlers/lsp.rs b/crates/proxy/src/handlers/lsp.rs deleted file mode 100644 index da33fe00..00000000 --- a/crates/proxy/src/handlers/lsp.rs +++ /dev/null @@ -1,278 +0,0 @@ -use actix_web::web::{Data, Json}; -use actix_web::HttpResponse; -use common::api_types::{JsonRpcErrorCode, JsonRpcMessage}; -use log::{debug, error, info, warn}; -use lsp_types::{ - DeclarationCapability, FoldingRangeProviderCapability, HoverProviderCapability, - ImplementationProviderCapability, InitializeResult, OneOf, PositionEncodingKind, - ServerCapabilities, ServerInfo, TextDocumentSyncKind, TextDocumentSyncOptions, -}; -use serde_json::Value; -use std::sync::Arc; -use std::sync::LazyLock; -use url::Url; - -use crate::container::ContainerOrchestrator; -use crate::handlers::container_proxy; -use crate::AppState; - -static SERVER_CAPABILITES: LazyLock = LazyLock::new(|| { - let mut capabilities = ServerCapabilities::default(); - capabilities.call_hierarchy_provider = Some(true.into()); - capabilities.declaration_provider = Some(DeclarationCapability::Simple(true)); - capabilities.definition_provider = Some(OneOf::Left(true)); - capabilities.document_symbol_provider = Some(OneOf::Left(true)); - capabilities.folding_range_provider = Some(FoldingRangeProviderCapability::Simple(true)); - capabilities.hover_provider = Some(HoverProviderCapability::Simple(false)); - capabilities.implementation_provider = Some(ImplementationProviderCapability::Simple(true)); - capabilities.inline_value_provider = Some(OneOf::Left(true)); - capabilities.position_encoding = Some(PositionEncodingKind::UTF16); - capabilities.references_provider = Some(OneOf::Left(true)); - capabilities.text_document_sync = Some( - TextDocumentSyncOptions { - open_close: Some(true), - change: Some(TextDocumentSyncKind::FULL), - ..Default::default() - } - .into(), - ); - capabilities.type_definition_provider = Some(true.into()); - capabilities -}); - -/// Forward LSP JSON-RPC requests to language servers -#[utoipa::path( - post, - path = "/lsp", - tag = "lsp", - request_body = JsonRpcMessage, - responses( - (status = 200, description = "LSP request processed successfully", body = JsonRpcMessage), - (status = 400, description = "Bad request - invalid JSON-RPC format"), - (status = 500, description = "Internal server error") - ) -)] -pub async fn lsp(data: Data, request: Json) -> HttpResponse { - let lsp_req = request.into_inner(); - - let method = &lsp_req.method; - let req_id = lsp_req.id.clone(); - info!("Received LSP request: id={:?} method={:?}", &req_id, method); - debug!("LSP request: {:?}", &lsp_req); - - // Handle lifecycle requests locally - if let Ok(result) = handle_lifecycle_request(&lsp_req) { - if let Some(response) = result { - return HttpResponse::Ok().json(response); - } else { - return HttpResponse::Ok().finish(); - } - } - - // For language feature requests, extract document URI and route to appropriate backend - let document_uri = match extract_document_uri(&lsp_req) { - Some(uri) => uri, - None => { - warn!( - "Could not extract document URI from request for method: {:?}", - method - ); - return HttpResponse::BadRequest().json(JsonRpcMessage::new_error_response( - req_id, - JsonRpcErrorCode::InvalidParams, - "Invalid params: could not extract document URI", - )); - } - }; - - // Convert file:// URI to path - let file_path = match Url::parse(&document_uri) { - Ok(url) if url.scheme() == "file" => match url.to_file_path() { - Ok(path) => path.to_string_lossy().to_string(), - Err(_) => { - error!("Invalid file URI path: {}", document_uri); - return HttpResponse::BadRequest().json(JsonRpcMessage::new_error_response( - req_id, - JsonRpcErrorCode::InvalidParams, - "Invalid params: invalid file URI path", - )); - } - }, - Ok(_) => { - error!("Non-file URI: {}", document_uri); - return HttpResponse::BadRequest().json(JsonRpcMessage::new_error_response( - req_id, - JsonRpcErrorCode::InvalidParams, - "Invalid params: expected file URI", - )); - } - Err(e) => { - error!("Invalid document URI: {} - {}", document_uri, e); - return HttpResponse::BadRequest().json(JsonRpcMessage::new_error_response( - req_id, - JsonRpcErrorCode::InvalidParams, - "Invalid params: invalid document URI", - )); - } - }; - - // Get container client for this file's language - let client = match container_proxy::get_api_client_for_file(&data.orchestrator, &file_path) - .await - { - Ok(client) => client, - Err(e) => { - error!("Failed to get container client: {}", e); - return HttpResponse::InternalServerError().json(JsonRpcMessage::new_error_response( - req_id, - JsonRpcErrorCode::InternalError, - &format!("Internal error: {}", e), - )); - } - }; - - // Convert request paths from host to container - let mut converted_request = lsp_req.clone(); - if let Some(ref mut params) = converted_request.params { - if let Err(e) = convert_json_paths_host_to_container(&data.orchestrator, params).await { - error!("Failed to convert request paths: {}", e); - return HttpResponse::InternalServerError().json(JsonRpcMessage::new_error_response( - req_id, - JsonRpcErrorCode::InternalError, - &format!("Path conversion error: {}", e), - )); - } - } - - // Forward request to container - match client.lsp(&converted_request).await { - Ok(mut response) => { - info!("Received container response: id={:?}", response.id); - debug!("Container response: {:?}", response); - - // Convert response paths from container to host - if let Some(ref mut result) = response.result { - if let Err(e) = - convert_json_paths_container_to_host(&data.orchestrator, result).await - { - error!("Failed to convert response paths: {}", e); - return HttpResponse::InternalServerError().json( - JsonRpcMessage::new_error_response( - req_id, - JsonRpcErrorCode::InternalError, - &format!("Path conversion error: {}", e), - ), - ); - } - } - - HttpResponse::Ok().json(response) - } - Err(e) => { - error!("Container request failed: {}", e); - HttpResponse::InternalServerError().json(JsonRpcMessage::new_error_response( - req_id, - JsonRpcErrorCode::InternalError, - &format!("Internal error: {}", e), - )) - } - } -} - -/// Handle lifecycle requests locally, returning a JSON-RPC response if handled -pub fn handle_lifecycle_request(request: &JsonRpcMessage) -> Result, ()> { - let req_id = request.id.clone(); - let Some(method) = request.method.as_ref() else { - return Err(()); - }; - let response = match method.as_str() { - "initialize" => Some(JsonRpcMessage::new_result_response( - req_id, - InitializeResult { - capabilities: SERVER_CAPABILITES.clone(), - server_info: Some(ServerInfo { - name: "nuanced-lsp".to_string(), - version: Some(env!("CARGO_PKG_VERSION").to_string()), - }), - }, - )), - "initialized" | "exit" => None, // No response for notifications - "shutdown" => Some(JsonRpcMessage::new_result_response(req_id, Value::Null)), - dollar_method if dollar_method.starts_with("$/") => { - if req_id.is_some() { - Some(JsonRpcMessage::new_error_response( - req_id, - JsonRpcErrorCode::MethodNotFound as i32, - "Method not found".to_string(), - )) - } else { - None - } - } - _ => return Err(()), - }; - Ok(response) -} - -/// Extract document URI from JSON-RPC request -pub fn extract_document_uri(request: &JsonRpcMessage) -> Option { - request - .params - .as_ref()? - .get("textDocument") - .and_then(|td| td.get("uri")) - .and_then(|u| u.as_str()) - .map(|uri| uri.to_string()) -} - -/// Recursively convert paths in JSON value from host to container -pub fn convert_json_paths_host_to_container<'a>( - orchestrator: &'a Arc, - value: &'a mut Value, -) -> std::pin::Pin> + 'a>> { - Box::pin(async move { - let host_workspace = orchestrator - .get_host_workspace_path() - .await - .ok_or_else(|| "Failed to get host workspace path".to_string())?; - - convert_json_paths_recursive(value, &host_workspace, "/mnt/workspace"); - Ok(()) - }) -} - -/// Recursively replace path strings in JSON values -fn convert_json_paths_recursive(value: &mut Value, from: &str, to: &str) { - match value { - Value::String(s) => { - *s = s.replace(from, to); - } - Value::Object(map) => { - for (_, v) in map.iter_mut() { - convert_json_paths_recursive(v, from, to); - } - } - Value::Array(arr) => { - for item in arr.iter_mut() { - convert_json_paths_recursive(item, from, to); - } - } - _ => {} - } -} - -/// Recursively convert paths in JSON value from container to host -pub fn convert_json_paths_container_to_host<'a>( - orchestrator: &'a Arc, - value: &'a mut Value, -) -> std::pin::Pin> + 'a>> { - Box::pin(async move { - let host_workspace = orchestrator - .get_host_workspace_path() - .await - .ok_or_else(|| "Failed to get host workspace path".to_string())?; - - convert_json_paths_recursive(value, "/mnt/workspace", &host_workspace); - Ok(()) - }) -} diff --git a/crates/proxy/src/handlers/lsp_ws.rs b/crates/proxy/src/handlers/lsp_ws.rs index 1255acae..990cdbcc 100644 --- a/crates/proxy/src/handlers/lsp_ws.rs +++ b/crates/proxy/src/handlers/lsp_ws.rs @@ -1,20 +1,23 @@ +use crate::container::ContainerOrchestrator; use crate::handlers::container_proxy; -use crate::handlers::lsp::{ - convert_json_paths_container_to_host, convert_json_paths_host_to_container, - extract_document_uri, handle_lifecycle_request, -}; use crate::AppState; use actix_web::web::{Data, Payload}; use actix_web::{HttpRequest, HttpResponse}; use actix_ws::{Message as WsMessage, MessageStream, Session}; -use common::api_types::{JsonRpcMessage, SupportedLanguages}; +use common::api_types::{JsonRpcErrorCode, JsonRpcMessage, SupportedLanguages}; use common::utils::language_utils::detect_language; use futures_util::stream::SplitSink; use futures_util::{SinkExt, StreamExt}; use log::{debug, error, info, warn}; +use lsp_types::{ + DeclarationCapability, FoldingRangeProviderCapability, HoverProviderCapability, + ImplementationProviderCapability, InitializeResult, OneOf, PositionEncodingKind, + ServerCapabilities, ServerInfo, TextDocumentSyncKind, TextDocumentSyncOptions, +}; +use serde_json::Value; use std::collections::hash_map::Entry; use std::collections::HashMap; -use std::sync::Arc; +use std::sync::{Arc, LazyLock}; use tokio_tungstenite::tungstenite::Message as TungsteniteMessage; use tokio_tungstenite::{MaybeTlsStream, WebSocketStream}; use url::Url; @@ -22,6 +25,30 @@ use url::Url; type ContainerWsConnection = WebSocketStream>; type ContainerWsSink = SplitSink; +static SERVER_CAPABILITES: LazyLock = LazyLock::new(|| { + let mut capabilities = ServerCapabilities::default(); + capabilities.call_hierarchy_provider = Some(true.into()); + capabilities.declaration_provider = Some(DeclarationCapability::Simple(true)); + capabilities.definition_provider = Some(OneOf::Left(true)); + capabilities.document_symbol_provider = Some(OneOf::Left(true)); + capabilities.folding_range_provider = Some(FoldingRangeProviderCapability::Simple(true)); + capabilities.hover_provider = Some(HoverProviderCapability::Simple(false)); + capabilities.implementation_provider = Some(ImplementationProviderCapability::Simple(true)); + capabilities.inline_value_provider = Some(OneOf::Left(true)); + capabilities.position_encoding = Some(PositionEncodingKind::UTF16); + capabilities.references_provider = Some(OneOf::Left(true)); + capabilities.text_document_sync = Some( + TextDocumentSyncOptions { + open_close: Some(true), + change: Some(TextDocumentSyncKind::FULL), + ..Default::default() + } + .into(), + ); + capabilities.type_definition_provider = Some(true.into()); + capabilities +}); + /// WebSocket endpoint for bidirectional LSP communication pub async fn lsp_ws( req: HttpRequest, @@ -294,3 +321,101 @@ async fn send_to_container( .await .map_err(|e| e.into()) } + +/// Handle lifecycle requests locally, returning a JSON-RPC response if handled +fn handle_lifecycle_request(request: &JsonRpcMessage) -> Result, ()> { + let req_id = request.id.clone(); + let Some(method) = request.method.as_ref() else { + return Err(()); + }; + let response = match method.as_str() { + "initialize" => Some(JsonRpcMessage::new_result_response( + req_id, + InitializeResult { + capabilities: SERVER_CAPABILITES.clone(), + server_info: Some(ServerInfo { + name: "nuanced-lsp".to_string(), + version: Some(env!("CARGO_PKG_VERSION").to_string()), + }), + }, + )), + "initialized" | "exit" => None, // No response for notifications + "shutdown" => Some(JsonRpcMessage::new_result_response(req_id, Value::Null)), + dollar_method if dollar_method.starts_with("$/") => { + if req_id.is_some() { + Some(JsonRpcMessage::new_error_response( + req_id, + JsonRpcErrorCode::MethodNotFound as i32, + "Method not found".to_string(), + )) + } else { + None + } + } + _ => return Err(()), + }; + Ok(response) +} + +/// Extract document URI from JSON-RPC request +fn extract_document_uri(request: &JsonRpcMessage) -> Option { + request + .params + .as_ref()? + .get("textDocument") + .and_then(|td| td.get("uri")) + .and_then(|u| u.as_str()) + .map(|uri| uri.to_string()) +} + +/// Recursively convert paths in JSON value from host to container +fn convert_json_paths_host_to_container<'a>( + orchestrator: &'a Arc, + value: &'a mut Value, +) -> std::pin::Pin> + 'a>> { + Box::pin(async move { + let host_workspace = orchestrator + .get_host_workspace_path() + .await + .ok_or_else(|| "Failed to get host workspace path".to_string())?; + + convert_json_paths_recursive(value, &host_workspace, "/mnt/workspace"); + Ok(()) + }) +} + +/// Recursively replace path strings in JSON values +fn convert_json_paths_recursive(value: &mut Value, from: &str, to: &str) { + match value { + Value::String(s) => { + *s = s.replace(from, to); + } + Value::Object(map) => { + for (_, v) in map.iter_mut() { + convert_json_paths_recursive(v, from, to); + } + } + Value::Array(arr) => { + for item in arr.iter_mut() { + convert_json_paths_recursive(item, from, to); + } + } + _ => {} + } +} + +/// Recursively convert paths in JSON value from container to host +fn convert_json_paths_container_to_host<'a>( + orchestrator: &'a Arc, + value: &'a mut Value, +) -> std::pin::Pin> + 'a>> { + Box::pin(async move { + let host_workspace = orchestrator + .get_host_workspace_path() + .await + .ok_or_else(|| "Failed to get host workspace path".to_string())?; + + convert_json_paths_recursive(value, "/mnt/workspace", &host_workspace); + Ok(()) + }) +} diff --git a/crates/proxy/src/handlers/mod.rs b/crates/proxy/src/handlers/mod.rs index c5a13ff7..2481d430 100644 --- a/crates/proxy/src/handlers/mod.rs +++ b/crates/proxy/src/handlers/mod.rs @@ -5,12 +5,11 @@ mod find_referenced_symbols; mod find_references; mod health; mod list_files; -mod lsp; mod lsp_ws; mod read_source_code; pub(crate) mod container_proxy; pub use self::{ definitions_in_file::*, find_definition::*, find_identifier::*, find_referenced_symbols::*, - find_references::*, health::*, list_files::*, lsp::*, lsp_ws::*, read_source_code::*, + find_references::*, health::*, list_files::*, lsp_ws::*, read_source_code::*, }; diff --git a/crates/proxy/src/lib.rs b/crates/proxy/src/lib.rs index 07c3b54d..8c300b26 100644 --- a/crates/proxy/src/lib.rs +++ b/crates/proxy/src/lib.rs @@ -1,11 +1,12 @@ use actix_cors::Cors; mod middleware; use actix_web::{ - web::{get, post, resource, scope, Data}, + web::{get, post, scope, Data}, App, HttpServer, }; use log::{error, info, warn}; use middleware::JwtMiddleware; +use std::env; use std::fs; use std::fs::File; use std::io::Write; @@ -21,7 +22,7 @@ mod handlers; use crate::handlers::{ definitions_in_file, find_definition, find_identifier, find_referenced_symbols, - find_references, health_check, list_files, lsp, lsp_ws, read_source_code, + find_references, health_check, list_files, lsp_ws, read_source_code, }; use common::api_types::{ get_mount_dir, set_global_mount_dir, CodeContext, DefinitionsInFileRequest, ErrorResponse, @@ -175,13 +176,6 @@ pub async fn initialize_app_state_with_mount_dir( })) } -// Helper enum for cleaner matching -#[derive(Debug)] -enum Method { - Get, - Post, -} - pub async fn run_server(app_state: Data) -> std::io::Result<()> { run_server_with_host(app_state, "0.0.0.0").await } @@ -232,74 +226,37 @@ pub async fn run_server_with_port_and_host( // Initialize JWT middleware once before creating workers to fail fast // If this panics, it happens in the main thread before any workers start let jwt_middleware = if middleware::is_auth_enabled() { - match JwtMiddleware::from_env() { - Ok(middleware) => Some(middleware), - Err(e) => { - error!("Failed to initialize JWT middleware: {}", e); - std::process::exit(1); - } - } + JwtMiddleware::from_env().unwrap_or_else(|e| { + error!("Failed to initialize JWT middleware: {}", e); + std::process::exit(1); + }) } else { - None + JwtMiddleware::disabled() }; HttpServer::new(move || { - let mut api_scope = scope(format!("/{}", server_path).as_str()); - - // Add routes based on OpenAPI paths - for (path, path_item) in openapi.paths.paths.iter() { - let method = if path_item.get.is_some() { - Some(Method::Get) - } else if path_item.post.is_some() { - Some(Method::Post) - } else { - None - }; - - api_scope = match (path.as_str(), method) { - ("/symbol/definitions-in-file", Some(Method::Get)) => - api_scope.service(resource(path).route(get().to(definitions_in_file))), - ("/symbol/find-definition", Some(Method::Post)) => - api_scope.service(resource(path).route(post().to(find_definition))), - ("/symbol/find-identifier", Some(Method::Post)) => - api_scope.service(resource(path).route(post().to(find_identifier))), - ("/symbol/find-referenced-symbols", Some(Method::Post)) => - api_scope.service(resource(path).route(post().to(find_referenced_symbols))), - ("/symbol/find-references", Some(Method::Post)) => - api_scope.service(resource(path).route(post().to(find_references))), - ("/system/health", Some(Method::Get)) => - api_scope.service(resource(path).route(get().to(health_check))), - ("/workspace/list-files", Some(Method::Get)) => - api_scope.service(resource(path).route(get().to(list_files))), - ("/workspace/read-source-code", Some(Method::Post)) => - api_scope.service(resource(path).route(post().to(read_source_code))), - (p, m) => panic!( - "Invalid path configuration for {}: {:?}. Ensure the OpenAPI spec matches your handlers.", - p, - m - ) - }; - } - App::new() .wrap(Cors::permissive()) + .wrap(jwt_middleware.clone()) .app_data(app_state.clone()) .configure(|cfg| { - if let Some(ref middleware) = jwt_middleware { - cfg - .service(api_scope.wrap(middleware.clone())) - .route("/lsp", post().to(lsp).wrap(middleware.clone())) - .route("/lsp/ws", get().to(lsp_ws).wrap(middleware.clone())); - } else { - cfg - .service(api_scope) - .route("/lsp", post().to(lsp)) - .route("/lsp/ws", get().to(lsp_ws)); - } + cfg.route("/lsp/ws", get().to(lsp_ws)).service( + scope(format!("/{}", server_path).as_str()) + .route("/symbol/definitions-in-file", get().to(definitions_in_file)) + .route("/symbol/find-definition", post().to(find_definition)) + .route("/symbol/find-identifier", post().to(find_identifier)) + .route( + "/symbol/find-referenced-symbols", + post().to(find_referenced_symbols), + ) + .route("/symbol/find-references", post().to(find_references)) + .route("/system/health", get().to(health_check)) + .route("/workspace/list-files", get().to(list_files)) + .route("/workspace/read-source-code", post().to(read_source_code)), + ); }) .service( - SwaggerUi::new("/swagger-ui/{_:.*}") - .url("/api-docs/openapi.json", openapi.clone()) + SwaggerUi::new("/swagger-ui/{_:.*}").url("/api-docs/openapi.json", openapi.clone()), ) }) .bind(format!("{}:{}", host, port))? diff --git a/crates/proxy/src/middleware/jwt.rs b/crates/proxy/src/middleware/jwt.rs index c7f9d524..8eb0af4b 100644 --- a/crates/proxy/src/middleware/jwt.rs +++ b/crates/proxy/src/middleware/jwt.rs @@ -21,6 +21,11 @@ pub struct JwtMiddleware { } impl JwtMiddleware { + /// Create middleware with auth disabled + pub fn disabled() -> Self { + Self { secret: String::default() } + } + /// Create middleware from environment variable (for production use) pub fn from_env() -> Result { let secret = env::var("JWT_SECRET") @@ -73,6 +78,10 @@ where forward_ready!(service); fn call(&self, req: ServiceRequest) -> Self::Future { + if self.secret.is_empty() { + return Box::pin(self.service.call(req)); + } + let auth_header = req.headers().get("Authorization"); let secret = self.secret.clone(); diff --git a/crates/wrapper/src/handlers/lsp.rs b/crates/wrapper/src/handlers/lsp.rs deleted file mode 100644 index a44d368d..00000000 --- a/crates/wrapper/src/handlers/lsp.rs +++ /dev/null @@ -1,50 +0,0 @@ -use crate::AppState; -use actix_web::{web, HttpResponse}; -use common::api_types::{JsonRpcErrorCode, JsonRpcMessage}; -use log::{debug, error, info}; - -/// Forward raw LSP JSON-RPC requests to the LSP server -/// -/// This endpoint provides direct access to the LSP server by forwarding -/// JSON-RPC requests and returning responses with minimal processing. -#[utoipa::path( - post, - path = "/lsp", - request_body = JsonRpcMessage, - responses( - (status = 200, description = "Identifier retrieved successfully", body = JsonRpcMessage), - (status = 400, description = "Bad request"), - (status = 500, description = "Internal server error") - ) -)] -pub async fn lsp( - app_state: web::Data, - request: web::Json, -) -> HttpResponse { - let lsp_req = request.into_inner(); - let req_id = lsp_req.id.clone(); - - info!( - "Received LSP request: id={:?} method={:?}", - &req_id, &lsp_req.method - ); - debug!("LSP request: {:?}", &lsp_req); - - // Forward the request to the LSP server - match app_state.api_manager.lsp(lsp_req).await { - Ok(response) => { - info!("Received process response: id={:?}", response.id); - debug!("Process response: {:?}", response); - HttpResponse::Ok().json(response) - } - Err(e) => { - error!("LSP request failed: {}", e); - let error = JsonRpcMessage::new_error_response( - req_id, - JsonRpcErrorCode::InternalError, - format!("Internal error: {}", e), - ); - HttpResponse::InternalServerError().json(error) - } - } -} diff --git a/crates/wrapper/src/handlers/mod.rs b/crates/wrapper/src/handlers/mod.rs index af7e5e01..4866e673 100644 --- a/crates/wrapper/src/handlers/mod.rs +++ b/crates/wrapper/src/handlers/mod.rs @@ -4,7 +4,6 @@ pub mod find_definition; pub mod find_identifier; pub mod find_referenced_symbols; pub mod find_references; -pub mod lsp; pub mod lsp_ws; pub mod utils; diff --git a/crates/wrapper/src/main.rs b/crates/wrapper/src/main.rs index f82189f0..c2f8d62d 100644 --- a/crates/wrapper/src/main.rs +++ b/crates/wrapper/src/main.rs @@ -261,7 +261,6 @@ async fn main() -> std::io::Result<()> { HttpServer::new(move || { App::new() .app_data(app_state.clone()) - .route("/lsp", web::post().to(handlers::lsp::lsp)) .route("/lsp/ws", web::get().to(handlers::lsp_ws::lsp_ws)) .route( "/symbol/definitions-in-file", diff --git a/scripts/test-all-endpoints.sh b/scripts/test-all-endpoints.sh index 3a06c394..d59c9ac9 100755 --- a/scripts/test-all-endpoints.sh +++ b/scripts/test-all-endpoints.sh @@ -581,14 +581,6 @@ while IFS='|' read -r lang test_file symbol_name symbol_line symbol_char health_ "200" \ "jq -e 'type == \"object\"' > /dev/null" - # Find Definition (assert selected identifier and at least one definition) - test_http_endpoint "LSP GoTo Definition ($lang)" \ - "POST" \ - "/lsp" \ - "{\"jsonrpc\":\"2.0\",\"id\":\"$TOTAL_TESTS\",\"method\":\"textDocument/definition\",\"params\":{\"textDocument\":{\"uri\":\"$test_uri\"},\"position\":{\"line\":$symbol_line,\"character\":$symbol_char}}}" \ - "200" \ - "jq -e '.result | if type == \"array\" then . else [.] end | length > 0' > /dev/null" - # Find Definition (assert selected identifier and at least one definition) test_ws_endpoint "LSP-WS GoTo Definition ($lang)" \ "/lsp/ws" \ From dfc83049075621d6eb7aa8f95466cdc6f01de5fd Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Mon, 19 Jan 2026 17:37:24 +0100 Subject: [PATCH 64/92] Cleanup OpenAPI documentation --- crates/common/src/api_types.rs | 7 +- crates/proxy/src/handlers/list_files.rs | 2 +- crates/proxy/src/handlers/read_source_code.rs | 14 +- crates/proxy/src/lib.rs | 10 +- crates/proxy/src/middleware/jwt.rs | 4 +- openapi.json | 144 ++++-------------- 6 files changed, 53 insertions(+), 128 deletions(-) diff --git a/crates/common/src/api_types.rs b/crates/common/src/api_types.rs index 5ba75c6f..6aacacc3 100644 --- a/crates/common/src/api_types.rs +++ b/crates/common/src/api_types.rs @@ -783,8 +783,6 @@ pub struct FindReferencedSymbolsResponse { pub not_found: Vec, } -pub type SymbolResponse = Vec; - impl From for FilePosition { fn from(location: Location) -> Self { FilePosition { @@ -845,6 +843,11 @@ pub struct ReadSourceCodeRequest { pub range: Option, } +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct ReadSourceCodeResponse { + pub source_code: String, +} + /// Unified JSON-RPC message /// /// Because multiple message types flow in both directions (requests & notifications diff --git a/crates/proxy/src/handlers/list_files.rs b/crates/proxy/src/handlers/list_files.rs index f5000335..ea86db8a 100644 --- a/crates/proxy/src/handlers/list_files.rs +++ b/crates/proxy/src/handlers/list_files.rs @@ -12,7 +12,7 @@ use std::sync::{Arc, Mutex}; path = "/workspace/list-files", tag = "file", responses( - (status = 200, description = "Files listed successfully"), + (status = 200, description = "Files listed successfully", body = Vec), (status = 500, description = "Internal server error") ) )] diff --git a/crates/proxy/src/handlers/read_source_code.rs b/crates/proxy/src/handlers/read_source_code.rs index db015d6f..9e027535 100644 --- a/crates/proxy/src/handlers/read_source_code.rs +++ b/crates/proxy/src/handlers/read_source_code.rs @@ -1,16 +1,10 @@ use crate::AppState; use actix_web::web::{Data, Json}; use actix_web::HttpResponse; -use common::api_types::{ErrorResponse, ReadSourceCodeRequest}; +use common::api_types::{ErrorResponse, ReadSourceCodeRequest, ReadSourceCodeResponse}; use log::{error, info}; -use serde::Serialize; use std::path::PathBuf; -#[derive(Serialize)] -struct ReadSourceResponse { - source_code: String, -} - /// Read source code from a file #[utoipa::path( post, @@ -18,7 +12,7 @@ struct ReadSourceResponse { tag = "file", request_body = ReadSourceCodeRequest, responses( - (status = 200, description = "Source code retrieved successfully"), + (status = 200, description = "Source code retrieved successfully", body = ReadSourceCodeResponse), (status = 400, description = "Bad request"), (status = 500, description = "Internal server error") ) @@ -79,9 +73,9 @@ pub async fn read_source_code( let selected_lines = &lines[start_line..end_line]; let source_code = selected_lines.join("\n"); - HttpResponse::Ok().json(ReadSourceResponse { source_code }) + HttpResponse::Ok().json(ReadSourceCodeResponse { source_code }) } else { - HttpResponse::Ok().json(ReadSourceResponse { + HttpResponse::Ok().json(ReadSourceCodeResponse { source_code: content, }) } diff --git a/crates/proxy/src/lib.rs b/crates/proxy/src/lib.rs index 8c300b26..8f8f3c58 100644 --- a/crates/proxy/src/lib.rs +++ b/crates/proxy/src/lib.rs @@ -28,8 +28,9 @@ use common::api_types::{ get_mount_dir, set_global_mount_dir, CodeContext, DefinitionsInFileRequest, ErrorResponse, FilePosition, FileRange, FindDefinitionRequest, FindDefinitionResponse, FindIdentifierRequest, FindIdentifierResponse, FindReferencedSymbolsRequest, FindReferencedSymbolsResponse, - FindReferencesRequest, FindReferencesResponse, HealthResponse, Position, - ReferenceWithSymbolDefinitions, SupportedLanguages, Symbol, SymbolResponse, + FindReferencesRequest, FindReferencesResponse, HealthResponse, Identifier, Position, Range, + ReadSourceCodeRequest, ReadSourceCodeResponse, ReferenceWithSymbolDefinitions, + SupportedLanguages, Symbol, }; pub fn check_mount_dir() -> std::io::Result<()> { @@ -66,11 +67,14 @@ pub fn check_mount_dir() -> std::io::Result<()> { FindReferencesRequest, FindReferencesResponse, HealthResponse, + Identifier, Position, + Range, + ReadSourceCodeRequest, + ReadSourceCodeResponse, ReferenceWithSymbolDefinitions, SupportedLanguages, Symbol, - SymbolResponse, ) ), paths( diff --git a/crates/proxy/src/middleware/jwt.rs b/crates/proxy/src/middleware/jwt.rs index 8eb0af4b..9ca71c82 100644 --- a/crates/proxy/src/middleware/jwt.rs +++ b/crates/proxy/src/middleware/jwt.rs @@ -23,7 +23,9 @@ pub struct JwtMiddleware { impl JwtMiddleware { /// Create middleware with auth disabled pub fn disabled() -> Self { - Self { secret: String::default() } + Self { + secret: String::default(), + } } /// Create middleware from environment variable (for production use) diff --git a/openapi.json b/openapi.json index 89921cba..17bc6c76 100644 --- a/openapi.json +++ b/openapi.json @@ -7,12 +7,12 @@ "name": "MIT", "url": "https://opensource.org/licenses/MIT" }, - "version": "0.4.9" + "version": "0.5.0" }, "servers": [ { "url": "http://localhost:4444/v1", - "description": "Nuanced LSP API v1" + "description": "API server v1" } ], "paths": { @@ -249,7 +249,17 @@ "operationId": "list_files", "responses": { "200": { - "description": "Files listed successfully" + "description": "Files listed successfully", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + } }, "500": { "description": "Internal server error" @@ -276,7 +286,14 @@ }, "responses": { "200": { - "description": "Source code retrieved successfully" + "description": "Source code retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReadSourceCodeResponse" + } + } + } }, "400": { "description": "Bad request" @@ -307,7 +324,7 @@ }, "DefinitionsInFileRequest": { "type": "object", - "description": "Response to a definition request.\n\nThe definition(s) of the symbol.\nPoints to the start position of the symbol's identifier.\n\ne.g. for the definition of `User` on line 5 of `src/main.py` with the code:\n```text\n0: class User:\n_________^\n1: def __init__(self, name, age):\n2: self.name = name\n3: self.age = age\n4:\n5: user = User(\"John\", 30)\n__________^\n```\nThe definition(s) will be `[{\"path\": \"src/main.py\", \"line\": 0, \"character\": 6}]`.", + "description": "Request to get the symbols in a file.", "required": [ "file_path" ], @@ -719,18 +736,6 @@ "type": "string", "description": "Language version string (e.g., \"3.4.4\" for Ruby, \"3.12\" for Python)\n\nThis represents the runtime/compiler version of a language, not the container image version.\nFor languages without version detection, this may be None." }, - "LanguageVariant": { - "type": "string", - "description": "Language variant for languages that have multiple type system modes\n\nSome languages support different type-checking modes that require different\nlanguage servers or configurations. This enum captures those variants.", - "enum": [ - "standard", - "sorbet" - ] - }, - "LanguageVersion": { - "type": "string", - "description": "Language version string (e.g., \"3.4.4\" for Ruby, \"3.12\" for Python)\n\nThis represents the runtime/compiler version of a language, not the container image version.\nFor languages without version detection, this may be None." - }, "Position": { "type": "object", "description": "A position within a text document, using 0-based indexing", @@ -796,6 +801,17 @@ } } }, + "ReadSourceCodeResponse": { + "type": "object", + "required": [ + "source_code" + ], + "properties": { + "source_code": { + "type": "string" + } + } + }, "ReferenceWithSymbolDefinitions": { "type": "object", "description": "A reference to a symbol along with its definition(s) found in the workspace\n\ne.g. for a reference to `User` in `main.py`:\n```python\nuser = User(\"John\", 30)\n_______^\n```\nThis would contain:\n- The reference location and name (\"User\" at line 0)\n- The symbol definition(s) (e.g. \"class User\" in models.py)", @@ -815,68 +831,6 @@ } } }, - "ReferencedSymbolsResponse": { - "type": "object", - "description": "Response containing symbols referenced from the requested position\n\nThe symbols are categorized into:\n- workspace_symbols: References to symbols that were found and have definitions in the workspace\n- external_symbols: References to symbols from outside the workspace (built-in functions, external libraries)\n- not_found: References where the symbol definition could not be found", - "required": [ - "workspace_symbols", - "external_symbols", - "not_found" - ], - "properties": { - "external_symbols": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Identifier" - } - }, - "not_found": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Identifier" - } - }, - "workspace_symbols": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ReferenceWithSymbolDefinitions" - } - } - } - }, - "ReferencesResponse": { - "type": "object", - "description": "Response to a references request.\n\nPoints to the start position of the symbol's identifier.\n\ne.g. for the references of `User` on line 0 character 6 of `src/main.py` with the code:\n```text\n0: class User:\n1: def __init__(self, name, age):\n2: self.name = name\n3: self.age = age\n4:\n5: user = User(\"John\", 30)\n_________^\n6:\n7: print(user.name)\n```\nThe references will be `[{\"path\": \"src/main.py\", \"line\": 5, \"character\": 7}]`.", - "required": [ - "references", - "selected_identifier" - ], - "properties": { - "context": { - "type": [ - "array", - "null" - ], - "items": { - "$ref": "#/components/schemas/CodeContext" - }, - "description": "The source code around the references." - }, - "raw_response": { - "description": "The raw response from the langserver.\n\nhttps://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_references" - }, - "references": { - "type": "array", - "items": { - "$ref": "#/components/schemas/FilePosition" - } - }, - "selected_identifier": { - "$ref": "#/components/schemas/Identifier", - "description": "The identifier that was \"clicked-on\" to get the references." - } - } - }, "SupportedLanguages": { "oneOf": [ { @@ -991,38 +945,6 @@ "example": "User" } } - }, - "Vec": { - "type": "array", - "items": { - "type": "object", - "required": [ - "name", - "kind", - "identifier_position", - "file_range" - ], - "properties": { - "file_range": { - "$ref": "#/components/schemas/FileRange", - "description": "The full range of the symbol." - }, - "identifier_position": { - "$ref": "#/components/schemas/FilePosition", - "description": "The start position of the symbol's identifier." - }, - "kind": { - "type": "string", - "description": "The kind of the symbol (e.g., function, class).", - "example": "class" - }, - "name": { - "type": "string", - "description": "The name of the symbol.", - "example": "User" - } - } - } } }, "securitySchemes": { From 2cf242092a147ba74733e8ad30294dfa8c3400e1 Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Mon, 19 Jan 2026 17:39:10 +0100 Subject: [PATCH 65/92] Fix missing include --- scripts/test-all-endpoints.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/test-all-endpoints.sh b/scripts/test-all-endpoints.sh index d59c9ac9..2c871c3c 100755 --- a/scripts/test-all-endpoints.sh +++ b/scripts/test-all-endpoints.sh @@ -6,6 +6,7 @@ SCRIPT_DIR="$(cd "$(dirname "$(realpath "${BASH_SOURCE[0]}")")" && pwd)" source "$SCRIPT_DIR/include/colors.sh" source "$SCRIPT_DIR/include/constants.sh" +source "$SCRIPT_DIR/include/lib.sh" usage() { echo "Usage: $0 [--language-tag=TAG] [--no-cleanup] [--service-tag=TAG]" From f8400c91001fef7ad9334bdf40ee738e02d89727 Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Mon, 19 Jan 2026 17:57:00 +0100 Subject: [PATCH 66/92] Fix endpoint url --- scripts/test-all-endpoints.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/test-all-endpoints.sh b/scripts/test-all-endpoints.sh index 2c871c3c..18dc8ebb 100755 --- a/scripts/test-all-endpoints.sh +++ b/scripts/test-all-endpoints.sh @@ -61,7 +61,7 @@ for arg in "$@"; do done # Configuration -BASE_URL="http://localhost:4444/v1" +BASE_URL="http://localhost:4444" WORKSPACE_PATH="$(cd "$SCRIPT_DIR/../sample_project/all" && pwd)" # Counters @@ -445,6 +445,7 @@ else ready=false for i in $(seq 1 100); do HEALTH=$(curl -sf "${BASE_URL}/v1/system/health" || true) + echo "$HEALTH" | jq . STATUS=$(echo "$HEALTH" | jq -r '.status' 2>/dev/null || echo "") LANG_FAILED=$(echo "$HEALTH" | jq -r '.languages | to_entries[]? | select(.value == false) | .key' 2>/dev/null || true) From e661239dd447ed64976d6577a7b9b5ff1feb7f11 Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Mon, 19 Jan 2026 17:58:53 +0100 Subject: [PATCH 67/92] Update changelog --- CHANGELOG.services.md | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.services.md b/CHANGELOG.services.md index a7cad829..7476f7e7 100644 --- a/CHANGELOG.services.md +++ b/CHANGELOG.services.md @@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- Support for the proxy to act as a proper LSP server via the `/lsp/ws` + WebSocket endpoint. + ## [0.4.9] - 2025-01-15 ### Changes @@ -20,6 +27,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 with the `CONTAINER_REGISTRY` environment variable at build time or run time. The default is GHCR. -- Proxy initialization changed to allow failing fast if other containers fail to start. The proxy shuts down immediately if other service containers fail to start. The language status reported by the health endpoint now reports healthy / unhealthy. Previously a false status could also mean the language was still initializing. +- Proxy initialization changed to allow failing fast if other containers + fail to start. The proxy shuts down immediately if other service + containers fail to start. The language status reported by the health + endpoint now reports healthy / unhealthy. Previously a false status + could also mean the language was still initializing. -- The proxy will now always try to use local images when starting containers, before falling back to pulling and using images from the registry. +- The proxy will now always try to use local images when starting + containers, before falling back to pulling and using images from + the registry. From 7230a183849ec7948be8428434a1d722377a34b4 Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Tue, 20 Jan 2026 14:50:16 +0100 Subject: [PATCH 68/92] Consolidate test workflows and support testing client against development version of service --- .../workflows/{service-tests.yml => test.yml} | 152 ++++++++++++++---- .../workflows/typescript-client-release.yml | 6 + .github/workflows/typescript-client-tests.yml | 79 --------- clients/typescript/DEVELOPMENT.md | 6 +- 4 files changed, 126 insertions(+), 117 deletions(-) rename .github/workflows/{service-tests.yml => test.yml} (54%) delete mode 100644 .github/workflows/typescript-client-tests.yml diff --git a/.github/workflows/service-tests.yml b/.github/workflows/test.yml similarity index 54% rename from .github/workflows/service-tests.yml rename to .github/workflows/test.yml index d9b40e6f..c3abe0c8 100644 --- a/.github/workflows/service-tests.yml +++ b/.github/workflows/test.yml @@ -1,19 +1,15 @@ -name: Service Tests +name: Test Service and Client on: pull_request: paths-ignore: - - '.github/workflows/*-client-*' - '*.md' - - 'clients/**' - 'docs/**' push: branches: - main paths-ignore: - - '.github/workflows/*-client-*' - '*.md' - - 'clients/**' - 'docs/**' concurrency: @@ -24,11 +20,11 @@ permissions: contents: read env: - LANGUAGE_TAG: "1.0.0" + LANGUAGE_TAG: "1" SERVICE_TAG: actions-run-${{ github.run_id }} jobs: - check-rust: + service-check: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 @@ -50,9 +46,9 @@ jobs: - name: Run Rust fmt run: cargo fmt --check - build-images: + build-service-images: runs-on: ubuntu-latest - needs: check-rust + needs: service-check steps: - uses: actions/checkout@v6 @@ -63,7 +59,7 @@ jobs: # This gives us reproducible builds while still caching Rust compilation. - name: Build service images run: | - ./scripts/build-images.sh \ + ./scripts/build-service-images.sh \ --all-services --service-tag=${{ env.SERVICE_TAG }} \ --language-tag=${{ env.LANGUAGE_TAG }} \ --cache=gha \ @@ -76,17 +72,17 @@ jobs: nuanced-lsp-proxy:${{ env.SERVICE_TAG }} \ nuanced-lsp-wrapper:${{ env.SERVICE_TAG }} \ nuanced-lsp-watchdog:${{ env.SERVICE_TAG }} \ - -o build-artifacts/images.tar + -o build-artifacts/service-images.tar - uses: actions/upload-artifact@v6 with: - name: built-images - path: build-artifacts/images.tar + name: service-images + path: build-artifacts/service-images.tar retention-days: 1 - test-rust: + service-test-rust: runs-on: ubuntu-latest - needs: build-images + needs: build-service-images steps: - uses: actions/checkout@v6 - uses: dtolnay/rust-toolchain@stable @@ -100,11 +96,11 @@ jobs: - uses: actions/download-artifact@v6 with: - name: built-images + name: service-images path: build-artifacts - name: Load service images - run: docker load -i build-artifacts/images.tar + run: docker load -i build-artifacts/service-images.tar - name: Pre-pull Python language image from GHCR run: docker pull ghcr.io/nuanced-dev/nuanced-lsp-python:${{ env.LANGUAGE_TAG }} @@ -115,19 +111,19 @@ jobs: SERVICE_IMAGE_VERSION: ${{ env.SERVICE_TAG }} LANGUAGE_IMAGE_VERSION: ${{ env.LANGUAGE_TAG }} - test-container-lifecycle: + service-test-container-lifecycle: runs-on: ubuntu-latest - needs: build-images + needs: build-service-images steps: - uses: actions/checkout@v6 - uses: actions/download-artifact@v6 with: - name: built-images + name: service-images path: build-artifacts - name: Load images - run: docker load -i build-artifacts/images.tar + run: docker load -i build-artifacts/service-images.tar - name: Pre-pull Python language image from GHCR run: docker pull ghcr.io/nuanced-dev/nuanced-lsp-python:${{ env.LANGUAGE_TAG }} @@ -135,19 +131,19 @@ jobs: - name: Run container lifecycle tests run: ./scripts/test-container-lifecycle.sh --service-tag=${{ env.SERVICE_TAG }} --language-tag=${{ env.LANGUAGE_TAG }} - test-watchdog: + service-test-watchdog: runs-on: ubuntu-latest - needs: build-images + needs: build-service-images steps: - uses: actions/checkout@v6 - uses: actions/download-artifact@v6 with: - name: built-images + name: service-images path: build-artifacts - name: Load images - run: docker load -i build-artifacts/images.tar + run: docker load -i build-artifacts/service-images.tar - name: Pre-pull Python language image from GHCR run: docker pull ghcr.io/nuanced-dev/nuanced-lsp-python:${{ env.LANGUAGE_TAG }} @@ -155,9 +151,9 @@ jobs: - name: Run watchdog tests run: ./scripts/test-watchdog.sh --service-tag=${{ env.SERVICE_TAG }} --language-tag=${{ env.LANGUAGE_TAG }} - test-all-endpoints: + service-test-all-endpoints: runs-on: ubuntu-latest - needs: build-images + needs: build-service-images steps: - uses: actions/checkout@v6 @@ -171,28 +167,114 @@ jobs: - uses: actions/download-artifact@v6 with: - name: built-images + name: service-images path: build-artifacts - name: Load images - run: docker load -i build-artifacts/images.tar + run: docker load -i build-artifacts/service-images.tar - name: Pre-pull all language images from GHCR run: | - # Pull all language images in parallel for faster startup echo "Pulling language images from GHCR..." - docker pull ghcr.io/nuanced-dev/nuanced-lsp-python:${{ env.LANGUAGE_TAG }} & - docker pull ghcr.io/nuanced-dev/nuanced-lsp-typescript:${{ env.LANGUAGE_TAG }} & - docker pull ghcr.io/nuanced-dev/nuanced-lsp-rust:${{ env.LANGUAGE_TAG }} & - docker pull ghcr.io/nuanced-dev/nuanced-lsp-golang:${{ env.LANGUAGE_TAG }} & - docker pull ghcr.io/nuanced-dev/nuanced-lsp-java:${{ env.LANGUAGE_TAG }} & docker pull ghcr.io/nuanced-dev/nuanced-lsp-clangd:${{ env.LANGUAGE_TAG }} & docker pull ghcr.io/nuanced-dev/nuanced-lsp-csharp:${{ env.LANGUAGE_TAG }} & + docker pull ghcr.io/nuanced-dev/nuanced-lsp-golang:${{ env.LANGUAGE_TAG }} & + docker pull ghcr.io/nuanced-dev/nuanced-lsp-java:${{ env.LANGUAGE_TAG }} & docker pull ghcr.io/nuanced-dev/nuanced-lsp-php:${{ env.LANGUAGE_TAG }} & + docker pull ghcr.io/nuanced-dev/nuanced-lsp-python:${{ env.LANGUAGE_TAG }} & docker pull ghcr.io/nuanced-dev/nuanced-lsp-ruby-3.4.4:${{ env.LANGUAGE_TAG }} & docker pull ghcr.io/nuanced-dev/nuanced-lsp-ruby-sorbet-3.4.4:${{ env.LANGUAGE_TAG }} & + docker pull ghcr.io/nuanced-dev/nuanced-lsp-rust:${{ env.LANGUAGE_TAG }} & + docker pull ghcr.io/nuanced-dev/nuanced-lsp-typescript:${{ env.LANGUAGE_TAG }} & wait echo "All language images pulled successfully" - name: Run all-endpoints tests run: ./scripts/test-all-endpoints.sh --service-tag=${{ env.SERVICE_TAG }} --language-tag=${{ env.LANGUAGE_TAG }} + + typescript-client-check: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node-version: [18.x, 20.x, 22.x, 24.x] + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v6 + with: + node-version: ${{ matrix.node-version }} + + - name: Install dependencies + run: npm install + + - name: Build + run: npm run build + + - name: Lint + run: npm run lint + + test-typescript-client: + runs-on: ubuntu-latest + needs: + - typescript-client-check + - build-service-images + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node.js 24.x + uses: actions/setup-node@v6 + with: + node-version: 24.x + + - name: Install dependencies + run: npm install + + - name: Build + run: npm run build + + - uses: actions/download-artifact@v6 + with: + name: service-images + path: build-artifacts + + - name: Load images + run: docker load -i build-artifacts/service-images.tar + + - name: Pre-pull all language images from GHCR + run: | + echo "Pulling language images from GHCR..." + docker pull ghcr.io/nuanced-dev/nuanced-lsp-clangd:${{ env.LANGUAGE_TAG }} & + docker pull ghcr.io/nuanced-dev/nuanced-lsp-csharp:${{ env.LANGUAGE_TAG }} & + docker pull ghcr.io/nuanced-dev/nuanced-lsp-golang:${{ env.LANGUAGE_TAG }} & + docker pull ghcr.io/nuanced-dev/nuanced-lsp-java:${{ env.LANGUAGE_TAG }} & + docker pull ghcr.io/nuanced-dev/nuanced-lsp-php:${{ env.LANGUAGE_TAG }} & + docker pull ghcr.io/nuanced-dev/nuanced-lsp-python:${{ env.LANGUAGE_TAG }} & + docker pull ghcr.io/nuanced-dev/nuanced-lsp-ruby-3.2.2:${{ env.LANGUAGE_TAG }} & + docker pull ghcr.io/nuanced-dev/nuanced-lsp-ruby-3.2.6:${{ env.LANGUAGE_TAG }} & + docker pull ghcr.io/nuanced-dev/nuanced-lsp-ruby-3.3.5:${{ env.LANGUAGE_TAG }} & + docker pull ghcr.io/nuanced-dev/nuanced-lsp-ruby-3.3.6:${{ env.LANGUAGE_TAG }} & + docker pull ghcr.io/nuanced-dev/nuanced-lsp-ruby-3.4.1:${{ env.LANGUAGE_TAG }} & + docker pull ghcr.io/nuanced-dev/nuanced-lsp-ruby-3.4.2:${{ env.LANGUAGE_TAG }} & + docker pull ghcr.io/nuanced-dev/nuanced-lsp-ruby-3.4.4:${{ env.LANGUAGE_TAG }} & + docker pull ghcr.io/nuanced-dev/nuanced-lsp-ruby-sorbet-3.2.2:${{ env.LANGUAGE_TAG }} & + docker pull ghcr.io/nuanced-dev/nuanced-lsp-ruby-sorbet-3.2.6:${{ env.LANGUAGE_TAG }} & + docker pull ghcr.io/nuanced-dev/nuanced-lsp-ruby-sorbet-3.3.5:${{ env.LANGUAGE_TAG }} & + docker pull ghcr.io/nuanced-dev/nuanced-lsp-ruby-sorbet-3.3.6:${{ env.LANGUAGE_TAG }} & + docker pull ghcr.io/nuanced-dev/nuanced-lsp-ruby-sorbet-3.4.1:${{ env.LANGUAGE_TAG }} & + docker pull ghcr.io/nuanced-dev/nuanced-lsp-ruby-sorbet-3.4.2:${{ env.LANGUAGE_TAG }} & + docker pull ghcr.io/nuanced-dev/nuanced-lsp-ruby-sorbet-3.4.4:${{ env.LANGUAGE_TAG }} & + docker pull ghcr.io/nuanced-dev/nuanced-lsp-rust:${{ env.LANGUAGE_TAG }} & + docker pull ghcr.io/nuanced-dev/nuanced-lsp-typescript:${{ env.LANGUAGE_TAG }} & + wait + echo "All language images pulled successfully" + + - name: Test + run: npm run test + env: + NUANCED_LSP_TIMEOUT: 240 + FAIL_FAST: 1 diff --git a/.github/workflows/typescript-client-release.yml b/.github/workflows/typescript-client-release.yml index 9e7525db..311e9c32 100644 --- a/.github/workflows/typescript-client-release.yml +++ b/.github/workflows/typescript-client-release.yml @@ -50,6 +50,12 @@ jobs: working-directory: clients/typescript run: npm install + - name: Build + run: npm run build + + - name: Pull images to ensure they are published + run: dist/cli.cjs pull --all-services --all-languages + - name: Publish to npm run: npm publish diff --git a/.github/workflows/typescript-client-tests.yml b/.github/workflows/typescript-client-tests.yml deleted file mode 100644 index 69443aa2..00000000 --- a/.github/workflows/typescript-client-tests.yml +++ /dev/null @@ -1,79 +0,0 @@ -name: TypeScript Client Tests - -on: - pull_request: - paths: - - '.github/workflows/typescript-*' - - 'clients/typescript/**' - - '!clients/typescript/*.md' - - '!clients/typescript/docs/**' - push: - branches: - - main - paths: - - '.github/workflows/typescript-*' - - 'clients/typescript/**' - - '!clients/typescript/*.md' - - '!clients/typescript/docs/**' - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -permissions: - contents: read - -defaults: - run: - working-directory: clients/typescript - -jobs: - check: - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - node-version: [18.x, 20.x, 22.x, 24.x] - - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Setup Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v6 - with: - node-version: ${{ matrix.node-version }} - - - name: Install dependencies - run: npm install - - - name: Build - run: npm run build - - - name: Lint - run: npm run lint - - test: - runs-on: ubuntu-latest - needs: check - - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Setup Node.js 24.x - uses: actions/setup-node@v6 - with: - node-version: 24.x - - - name: Install dependencies - run: npm install - - - name: Build - run: npm run build - - - name: Test - run: npm run test - env: - NUANCED_LSP_TIMEOUT: 240 - FAIL_FAST: 1 diff --git a/clients/typescript/DEVELOPMENT.md b/clients/typescript/DEVELOPMENT.md index 12d22ba2..55fcb16e 100644 --- a/clients/typescript/DEVELOPMENT.md +++ b/clients/typescript/DEVELOPMENT.md @@ -112,11 +112,11 @@ For `0.x.y` versions, the minor version is treated like the major version. **Image versions:** -The TypeScript client depends on **published** versions of the service and language images. The versions used by the client are defined in `src/defaults.ts`. +The TypeScript client depend on the image versions defined in `src/defaults.ts`. -It is intentional that the client does not automatically depend the image versions in the repository. This allows developing the service before moving the client to this new version. +A local test run will use locally built images if available. The testing workflow will have service images built from the repo available. -_Note that if images and the client are updated in the same pull request, the images need to be released before the client tests can succeed in CI._ +The release workflow will **not** build any images and verify that all images can be pulled from the registry before publishign the release. ## Release From 6a8817a56ff0a2eea59da859a551d2ea072da594 Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Tue, 20 Jan 2026 14:50:53 +0100 Subject: [PATCH 69/92] Push remaining language-specific init login into LspConfig --- crates/wrapper/src/lsp/client.rs | 54 ++++++++++++++++----- crates/wrapper/src/lsp/languages/generic.rs | 14 +++--- crates/wrapper/src/lsp/languages/mod.rs | 13 ++++- crates/wrapper/src/main.rs | 42 ++-------------- 4 files changed, 63 insertions(+), 60 deletions(-) diff --git a/crates/wrapper/src/lsp/client.rs b/crates/wrapper/src/lsp/client.rs index abedcf4b..d53e8df7 100644 --- a/crates/wrapper/src/lsp/client.rs +++ b/crates/wrapper/src/lsp/client.rs @@ -22,6 +22,22 @@ use crate::lsp::json_rpc::JsonRpc; use crate::lsp::process::Process; use crate::lsp::{ExpectedMessageKey, JsonRpcHandler, PendingRequests, ProcessHandler}; +#[derive(Clone, Debug)] +pub enum PostInitializeMessage { + Request { + method: String, + params: Option, + }, + Notification { + method: String, + params: Option, + }, + ExpectNotification { + method: String, + params: serde_json::Value, + }, +} + #[async_trait] pub trait LspConfig: Send + Sync { async fn get_initialize_params( @@ -29,8 +45,8 @@ pub trait LspConfig: Send + Sync { root_path: String, ) -> Result>; - fn get_setup_workspace_method(&self) -> Option { - None + fn get_post_initialize_messages(&self) -> Vec { + vec![] } fn get_root_files(&mut self) -> Vec { @@ -129,6 +145,29 @@ impl LspClient { debug!("Initialization successful: {:?}", init_result); self.send_initialized().await?; + for message in self.config.get_post_initialize_messages() { + match message { + PostInitializeMessage::Request { method, params } => { + debug!("Post-init: calling request method: {}", method); + self.send_request(&method, params).await?; + } + PostInitializeMessage::Notification { method, params } => { + debug!("Post-init: sending notification: {}", method); + self.send_notification(&method, params).await?; + } + PostInitializeMessage::ExpectNotification { method, params } => { + debug!("Post-init: waiting for notification: {}", method); + let mut notification_rx = self + .expect_notification(ExpectedMessageKey { method, params }) + .await?; + // Wait indefinitely for ServiceReady notification + // The orchestrator health check and CLI timeout control overall timing + notification_rx.recv().await?; + } + } + } + debug!("Post-init successful"); + Ok(init_result) } @@ -426,17 +465,6 @@ impl LspClient { self.unexpected_notifications_tx.subscribe() } - pub async fn setup_workspace( - &mut self, - _root_path: &str, - ) -> Result<(), Box> { - if let Some(method) = self.config.get_setup_workspace_method() { - info!("Calling setup workspace method: {}", method); - self.send_request(&method, None).await?; - } - Ok(()) - } - pub async fn expect_notification( &mut self, key: ExpectedMessageKey, diff --git a/crates/wrapper/src/lsp/languages/generic.rs b/crates/wrapper/src/lsp/languages/generic.rs index 69952ecb..4bfe5b97 100644 --- a/crates/wrapper/src/lsp/languages/generic.rs +++ b/crates/wrapper/src/lsp/languages/generic.rs @@ -1,4 +1,4 @@ -use crate::lsp::client::{LspConfig, CLIENT_CAPABILITES}; +use crate::lsp::client::{LspConfig, PostInitializeMessage, CLIENT_CAPABILITES}; use async_trait::async_trait; use common::utils::file_utils::{search_paths, FileType}; @@ -12,7 +12,7 @@ use url::Url; #[derive(Clone)] pub struct GenericConfig { initialization_options: Option, - setup_workspace_method: Option, + post_initialize_messages: Vec, file_patterns: Vec, exclude_patterns: Vec, did_open_configuration: DidOpenConfiguration, @@ -35,8 +35,8 @@ impl LspConfig for GenericConfig { }) } - fn get_setup_workspace_method(&self) -> Option { - self.setup_workspace_method.clone() + fn get_post_initialize_messages(&self) -> Vec { + self.post_initialize_messages.clone() } fn get_root_files(&mut self) -> Vec { @@ -64,7 +64,7 @@ impl GenericConfig { ) -> Self { Self { initialization_options: None, - setup_workspace_method: None, + post_initialize_messages: vec![], file_patterns, exclude_patterns, did_open_configuration, @@ -129,8 +129,8 @@ impl GenericConfig { self } - pub fn with_setup_workspace_method(mut self, method: String) -> Self { - self.setup_workspace_method = Some(method); + pub fn with_post_initialize_messages(mut self, messages: Vec) -> Self { + self.post_initialize_messages = messages; self } } diff --git a/crates/wrapper/src/lsp/languages/mod.rs b/crates/wrapper/src/lsp/languages/mod.rs index 9e92bb3c..ee4bf80c 100644 --- a/crates/wrapper/src/lsp/languages/mod.rs +++ b/crates/wrapper/src/lsp/languages/mod.rs @@ -4,6 +4,7 @@ pub mod golang; pub mod python; pub mod sorbet; +use crate::lsp::client::PostInitializeMessage; use common::utils::workspace_documents::{ DidOpenConfiguration, CSHARP_FILE_PATTERNS, C_AND_CPP_FILE_PATTERNS, DEFAULT_EXCLUDE_PATTERNS, JAVA_FILE_PATTERNS, PHP_FILE_PATTERNS, PYTHON_FILE_PATTERNS, RUBY_FILE_PATTERNS, @@ -79,7 +80,10 @@ pub static RUST_CONFIG: LazyLock = LazyLock::new(|| { "sysroot": serde_json::Value::Null } })) - .with_setup_workspace_method("rust-analyzer/reloadWorkspace".to_string()) + .with_post_initialize_messages(vec![PostInitializeMessage::Request { + method: "rust-analyzer/reloadWorkspace".to_string(), + params: None, + }]) }); pub static JAVA_CONFIG: LazyLock = LazyLock::new(|| { @@ -91,6 +95,13 @@ pub static JAVA_CONFIG: LazyLock = LazyLock::new(|| { .collect(), DidOpenConfiguration::None, ) + .with_post_initialize_messages(vec![PostInitializeMessage::ExpectNotification { + method: "language/status".to_string(), + params: serde_json::json!({ + "type": "ServiceReady", + "message": "ServiceReady" + }), + }]) }); pub static C_AND_CPP_CONFIG: LazyLock = LazyLock::new(|| { diff --git a/crates/wrapper/src/main.rs b/crates/wrapper/src/main.rs index c2f8d62d..3e8305f5 100644 --- a/crates/wrapper/src/main.rs +++ b/crates/wrapper/src/main.rs @@ -45,7 +45,9 @@ pub struct AppState { pub api_manager: ApiManager, } -/// Health check endpoint - simple version that just returns OK +/// Health check endpoint +/// +/// The HTTP server only starts after the LSP server has initialized, so it simply returns OK. async fn health() -> impl Responder { HttpResponse::Ok().body("ok") } @@ -212,44 +214,6 @@ async fn main() -> std::io::Result<()> { std::io::Error::new(std::io::ErrorKind::Other, e) })?; - // Java-specific: Wait for ServiceReady notification - if language.as_str() == "java" { - use lsp::ExpectedMessageKey; - info!("Java: waiting for ServiceReady notification (no timeout - caller controls overall timeout)..."); - - let mut notification_rx = client - .expect_notification(ExpectedMessageKey { - method: "language/status".to_string(), - params: serde_json::json!({ - "type": "ServiceReady", - "message": "ServiceReady" - }), - }) - .await - .map_err(|e| { - error!("Failed to add ServiceReady notification listener: {}", e); - std::io::Error::new(std::io::ErrorKind::Other, e) - })?; - - // Wait indefinitely for ServiceReady notification - // The orchestrator health check and CLI timeout control overall timing - notification_rx.recv().await.map_err(|e| { - error!("Error receiving ServiceReady notification: {}", e); - std::io::Error::new(std::io::ErrorKind::Other, e) - })?; - - info!("Java: ServiceReady notification received!"); - } - - // Setup workspace (e.g., rust-analyzer/reloadWorkspace) - client - .setup_workspace(&args.workspace_path) - .await - .map_err(|e| { - error!("Failed to setup workspace: {}", e); - std::io::Error::new(std::io::ErrorKind::Other, e) - })?; - info!("LSP server started and initialized successfully"); let lsp_client = Arc::new(tokio::sync::Mutex::new(client)); From 561b88509b3e8a019320891c7bdaae0c8def4d60 Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Tue, 20 Jan 2026 14:53:23 +0100 Subject: [PATCH 70/92] Update client pull method --- clients/typescript/src/__generated/version.ts | 1 + clients/typescript/src/cli.ts | 85 ++++++------------- clients/typescript/src/client.ts | 81 +++++++++++++++--- clients/typescript/src/proxy.ts | 9 +- clients/typescript/src/types.ts | 13 +++ 5 files changed, 111 insertions(+), 78 deletions(-) diff --git a/clients/typescript/src/__generated/version.ts b/clients/typescript/src/__generated/version.ts index ae8863ac..f0ec300b 100644 --- a/clients/typescript/src/__generated/version.ts +++ b/clients/typescript/src/__generated/version.ts @@ -1,3 +1,4 @@ +/* eslint-disable */ // AUTO-GENERATED FILE. Do not edit. // Generated by scripts/generate-version.mjs export const VERSION = "0.5.0"; diff --git a/clients/typescript/src/cli.ts b/clients/typescript/src/cli.ts index 8f1d3ad3..48b4f418 100644 --- a/clients/typescript/src/cli.ts +++ b/clients/typescript/src/cli.ts @@ -408,14 +408,10 @@ async function pullCommand(opts: PullCommandOptions): Promise { sudo: getSudoFlag(opts), }); - // Import constants - const { ALL_SERVICES, ALL_LANGUAGES, selectLanguages } = - await import("./constants.js"); - // Parse services - let services: string[] = []; + let services: "all" | string[] | undefined; if (opts.allServices) { - services = [...ALL_SERVICES]; + services = "all"; } else if (opts.services) { services = opts.services .split(",") @@ -423,76 +419,43 @@ async function pullCommand(opts: PullCommandOptions): Promise { .filter(Boolean); } - // Parse languages and expand "ruby" and "ruby-sorbet" into all supported versions - let languages: string[] = []; + // Parse languages + let languages: "all" | string[] | undefined; if (opts.allLanguages) { - languages = [...ALL_LANGUAGES]; + languages = "all"; } else if (opts.languages) { - const names = opts.languages + languages = opts.languages .split(",") .map((s) => s.trim()) .filter(Boolean); - languages = selectLanguages(names, ALL_LANGUAGES); } // Validate at least one of services or languages is specified - if (services.length === 0 && languages.length === 0) { + if (services == undefined && languages == undefined) { log.err( "At least one of, --all-languages --all-services, --languages=, or --services= must be specified", ); process.exit(1); } - // Get registry and image versions - const containerRegistry = - opts.containerRegistry ?? - process.env.CONTAINER_REGISTRTY ?? - DEFAULT_CONTAINER_REGISTRY; - const languageVersion = - opts.languageImageVersion ?? - process.env.LANGUAGE_IMAGE_VERSION ?? - DEFAULT_LANGUAGE_IMAGE_VERSION; - const serviceVersion = - opts.serviceImageVersion ?? - process.env.SERVICE_IMAGE_VERSION ?? - DEFAULT_SERVICE_IMAGE_VERSION; - - // Build list of images to pull - const images: string[] = []; - for (const service of services) { - images.push( - `${containerRegistry}/nuanced-lsp-${service}:${serviceVersion}`, - ); - } - for (const language of languages) { - images.push( - `${containerRegistry}/nuanced-lsp-${language}:${languageVersion}`, - ); - } - - // Pull each image - let failed = false; - for (const image of images) { - if (!opts.json) log.info(`Pulling image '${image}'...`); - - const res = await client.pull(image, opts.stream); - - handleResult(res, { - json: !!opts.json, - fallbackErrMsg: `Failed to pull image '${image}`, - onSuccess: (data: PullResult) => { - log.info(`Successfully pulled image: ${data.image}`); - }, - onError: (err) => { - log.err(`Failed to pull image '${image}: ${err.message}`); - failed = true; - }, - }); - } + const res = await client.pull({ + services, + languages, + containerRegistry: opts.containerRegistry, + languageImageVersion: opts.languageImageVersion, + serviceImageVersion: opts.serviceImageVersion, + stream: opts.stream, + }); - if (failed) { - process.exit(1); - } + handleResult(res, { + json: !!opts.json, + fallbackErrMsg: "Failed to pull images", + onSuccess: (data: PullResult[]) => { + for (const result of data) { + log.info(`Successfully pulled image: ${result.image}`); + } + }, + }); } async function healthCommand(opts: HealthCommandOptions): Promise { diff --git a/clients/typescript/src/client.ts b/clients/typescript/src/client.ts index 2830f411..d99c260c 100644 --- a/clients/typescript/src/client.ts +++ b/clients/typescript/src/client.ts @@ -1,35 +1,39 @@ import type WebSocket from "ws"; import type { + ClientPullOptions, DefinitionsInFileResult, DockerResult, DownResult, FilePosition, FindDefinitionResult, FindIdentifierResult, - FindReferencesResult, FindReferencedSymbolsResult, + FindReferencesResult, HealthResult, HttpResult, IdentifierPosition, - UpResult, + JsonRpcMessage, ListFilesResult, + LogsResult, LspPosition, LspRange, + PullResult, ReadSourceResult, RunResult, StatusResult, - LogsResult, - PullResult, - JsonRpcMessage, + UpResult, } from "./types.js"; import { DEFAULT_CONTAINER_NAME, DEFAULT_CONTAINER_PORT, + DEFAULT_CONTAINER_REGISTRY, DEFAULT_HOST_PORT, DEFAULT_HOST_URL, - DEFAULT_TIMEOUT_SECS, + DEFAULT_LANGUAGE_IMAGE_VERSION, DEFAULT_RETRIES, + DEFAULT_SERVICE_IMAGE_VERSION, + DEFAULT_TIMEOUT_SECS, } from "./defaults.js"; import { err, ok } from "./types.js"; @@ -217,12 +221,67 @@ export class NuancedLspClient { return status(this.containerName, this.sudo); } - async pull( - image?: string, - stream?: boolean, - ): Promise> { + async pull(opts: ClientPullOptions): Promise> { const { pull } = await proxy(); - return pull(image, this.sudo, stream); + + // Import constants + const { ALL_SERVICES, ALL_LANGUAGES, selectLanguages } = + await import("./constants.js"); + + // Parse services + let services: string[] = []; + if (opts.services === "all") { + services = [...ALL_SERVICES]; + } else if (Array.isArray(opts.services)) { + services = opts.services; + } + + // Parse languages and expand "ruby" and "ruby-sorbet" into all supported versions + let languages: string[] = []; + if (opts.languages === "all") { + languages = [...ALL_LANGUAGES]; + } else if (Array.isArray(opts.languages)) { + languages = selectLanguages(opts.languages, ALL_LANGUAGES); + } + + // Get registry and image versions + const containerRegistry = + opts.containerRegistry ?? + process.env.CONTAINER_REGISTRTY ?? + DEFAULT_CONTAINER_REGISTRY; + const languageVersion = + opts.languageImageVersion ?? + process.env.LANGUAGE_IMAGE_VERSION ?? + DEFAULT_LANGUAGE_IMAGE_VERSION; + const serviceVersion = + opts.serviceImageVersion ?? + process.env.SERVICE_IMAGE_VERSION ?? + DEFAULT_SERVICE_IMAGE_VERSION; + + // Build list of images to pull + const images: string[] = []; + for (const service of services) { + images.push( + `${containerRegistry}/nuanced-lsp-${service}:${serviceVersion}`, + ); + } + for (const language of languages) { + images.push( + `${containerRegistry}/nuanced-lsp-${language}:${languageVersion}`, + ); + } + + // Pull each image + const results: PullResult[] = []; + for (const image of images) { + const res = await pull(image, this.sudo, opts.stream); + if (!res.ok) { + return res as DockerResult; + } + results.push(res.data); + } + + return { ok: true, data: results }; } // ---- Health (data-plane) -------------------------------------------------- diff --git a/clients/typescript/src/proxy.ts b/clients/typescript/src/proxy.ts index ec321af7..8a8ea687 100644 --- a/clients/typescript/src/proxy.ts +++ b/clients/typescript/src/proxy.ts @@ -677,14 +677,11 @@ export async function status( /** Pull image. Supports optional streaming. Never throws. */ export async function pull( - image?: string, + image: string, sudo?: boolean, stream?: boolean, ): Promise> { - const img = - image ?? - `${DEFAULT_CONTAINER_REGISTRY}/${PROXY_IMAGE_BASE}:${DEFAULT_SERVICE_IMAGE_VERSION}`; - const args = ["pull", img]; + const args = ["pull", image]; const r = runDockerCmd(args, { sudo, stream }); if (!r.ok) { return err({ @@ -694,5 +691,5 @@ export async function pull( stderr: r.stderr.trim(), }); } - return ok({ image: img, stdout: r.stdout.trim() }); + return ok({ image, stdout: r.stdout.trim() }); } diff --git a/clients/typescript/src/types.ts b/clients/typescript/src/types.ts index 6ded5b07..9f3e1685 100644 --- a/clients/typescript/src/types.ts +++ b/clients/typescript/src/types.ts @@ -71,6 +71,19 @@ export const PullResultSchema = z.object({ }); export type PullResult = Named; +export const ClientPullOptionsSchema = z.object({ + services: z.union([z.literal("all"), z.array(z.string())]).optional(), + languages: z.union([z.literal("all"), z.array(z.string())]).optional(), + containerRegistry: z.string().optional(), + languageImageVersion: z.string().optional(), + serviceImageVersion: z.string().optional(), + stream: z.boolean().optional(), +}); +export type ClientPullOptions = Named< + typeof ClientPullOptionsSchema, + "ClientPullOptions" +>; + // ---- General HttpResult types ----------------------------------------------- export const HttpErrSchema = z.object({ status_code: z.number().nullable(), From 11f859219db8134352fe40ceab52704e924e74af Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Tue, 20 Jan 2026 14:53:55 +0100 Subject: [PATCH 71/92] Bump service image dependency version --- clients/typescript/src/defaults.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/clients/typescript/src/defaults.ts b/clients/typescript/src/defaults.ts index fc8471f0..11eb996c 100644 --- a/clients/typescript/src/defaults.ts +++ b/clients/typescript/src/defaults.ts @@ -10,7 +10,7 @@ export const DEFAULT_CONTAINER_REGISTRY = "ghcr.io/nuanced-dev"; export const DEFAULT_LANGUAGE_IMAGE_VERSION = "1"; // Service image version -export const DEFAULT_SERVICE_IMAGE_VERSION = "0.4.9"; +export const DEFAULT_SERVICE_IMAGE_VERSION = "0.5.0"; // DEFAULT_BIND_HOST is the default host address to which the LSProxy container binds. // The default is the local loopback, implying the container only accepts connections from the same machine. From 13934d3c53d2104475ba310bd54d8bf271c8ed33 Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Tue, 20 Jan 2026 14:54:01 +0100 Subject: [PATCH 72/92] Update changelog --- clients/typescript/CHANGELOG.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/clients/typescript/CHANGELOG.md b/clients/typescript/CHANGELOG.md index 549cbab7..06201629 100644 --- a/clients/typescript/CHANGELOG.md +++ b/clients/typescript/CHANGELOG.md @@ -7,12 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -Support using Nuanced LSP as an LSP server through CLI and in VS Code. +### Added -- `nuanced-lsp` version 0.4.1 -- `nuanced-lsp-proxy` image version 0.5.0 -- `nuanced-lsp-watchdog` image version 0.5.0 -- `nuanced-lsp-wrapper` image version 0.5.0 +- A new `server` command runs Nuanced LSP as an LSP server. + +### Changed + +- Updated service dependency to version to 0.5.0. + +- The `pull` method now follows the command, and supports pulling service + and/or language images without having to construct the iamge names. ## [0.4.0] - 2025-01-14 From 0be4ef70dee17d9f67bbc84e865ee50fb4b5b570 Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Tue, 20 Jan 2026 14:57:34 +0100 Subject: [PATCH 73/92] Fix lint warning in generated file --- clients/typescript/eslint.config.js | 3 ++- clients/typescript/scripts/generate-version.mjs | 3 +-- clients/typescript/src/__generated/version.ts | 1 - 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/clients/typescript/eslint.config.js b/clients/typescript/eslint.config.js index 07c47d2c..412c6cba 100644 --- a/clients/typescript/eslint.config.js +++ b/clients/typescript/eslint.config.js @@ -106,10 +106,11 @@ export default [ }, { ignores: [ + "*.config.js", "coverage/", "dist/", - "*.config.js", "node_modules/", + "src/__generated/", "tests/workspaces/*/", ], }, diff --git a/clients/typescript/scripts/generate-version.mjs b/clients/typescript/scripts/generate-version.mjs index 1a4e4684..2a48a6af 100755 --- a/clients/typescript/scripts/generate-version.mjs +++ b/clients/typescript/scripts/generate-version.mjs @@ -23,8 +23,7 @@ const pkg = JSON.parse(fs.readFileSync(pkgJson, "utf8")); fs.mkdirSync(outDir, { recursive: true }); // Emit TypeScript module (formatted to pass Prettier) -const contents = `/* eslint-disable */ -// AUTO-GENERATED FILE. Do not edit. +const contents = `// AUTO-GENERATED FILE. Do not edit. // Generated by scripts/generate-version.mjs export const VERSION = ${JSON.stringify(pkg.version)}; `; diff --git a/clients/typescript/src/__generated/version.ts b/clients/typescript/src/__generated/version.ts index f0ec300b..ae8863ac 100644 --- a/clients/typescript/src/__generated/version.ts +++ b/clients/typescript/src/__generated/version.ts @@ -1,4 +1,3 @@ -/* eslint-disable */ // AUTO-GENERATED FILE. Do not edit. // Generated by scripts/generate-version.mjs export const VERSION = "0.5.0"; From dc1121cbaf99bec25dbeb298b199b5b92bb5fc3e Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Tue, 20 Jan 2026 15:43:44 +0100 Subject: [PATCH 74/92] Fix Ruby version detection in LSP websocket path --- crates/common/src/utils/language_utils.rs | 86 ++++++++++++----------- crates/proxy/src/handlers/lsp_ws.rs | 29 +++++++- 2 files changed, 72 insertions(+), 43 deletions(-) diff --git a/crates/common/src/utils/language_utils.rs b/crates/common/src/utils/language_utils.rs index 0de46a99..030ae004 100644 --- a/crates/common/src/utils/language_utils.rs +++ b/crates/common/src/utils/language_utils.rs @@ -10,6 +10,50 @@ use super::workspace_documents::{ TYPESCRIPT_AND_JAVASCRIPT_EXTENSIONS, TYPESCRIPT_EXTENSIONS, }; +/// Detect Ruby workspace and version configuration from a file path. +/// +/// Walks up the directory tree from the file path to find workspace markers +/// (.ruby-version or Gemfile), then detects the Ruby version and Sorbet configuration. +/// Returns the appropriate SupportedLanguages variant with version and config info. +fn detect_ruby_language(file_path: &Path) -> SupportedLanguages { + let mut workspace_path = file_path; + while let Some(parent) = workspace_path.parent() { + let has_ruby_version = parent.join(".ruby-version").exists(); + let has_gemfile = parent.join("Gemfile").exists(); + + if has_ruby_version || has_gemfile { + let version = detect_ruby_version(parent); + + // Only use Sorbet if BOTH conditions are met: + // 1. File has type annotations (# typed: comment) + // 2. Workspace has sorbet/config file + // This prevents spawning broken Sorbet containers that spin at 100% CPU + let sorbet_config_dir = if has_sorbet_type_annotation(file_path) { + find_sorbet_config_dir(file_path) + } else { + None + }; + let is_sorbet = sorbet_config_dir.is_some(); + + return SupportedLanguages::from_ruby_version_with_config( + &version, + is_sorbet, + sorbet_config_dir, + ); + } + + workspace_path = parent; + + // Stop at root directory + if parent.parent().is_none() { + break; + } + } + + // Fallback to default version if no workspace markers found + SupportedLanguages::ruby_default() +} + /// Detect the programming language from a file path. /// /// Returns a `SupportedLanguages` enum variant based on the file extension. @@ -33,47 +77,7 @@ pub fn detect_language(file_path: &str) -> Result ext if JAVA_EXTENSIONS.contains(&ext) => Ok(SupportedLanguages::Java), ext if GOLANG_EXTENSIONS.contains(&ext) => Ok(SupportedLanguages::Golang), ext if PHP_EXTENSIONS.contains(&ext) => Ok(SupportedLanguages::PHP), - ext if RUBY_EXTENSIONS.contains(&ext) => { - let path = Path::new(file_path); - - // Detect Ruby version by walking up to find .ruby-version or Gemfile - let mut workspace_path = path; - while let Some(parent) = workspace_path.parent() { - let has_ruby_version = parent.join(".ruby-version").exists(); - let has_gemfile = parent.join("Gemfile").exists(); - - if has_ruby_version || has_gemfile { - let version = detect_ruby_version(parent); - - // Only use Sorbet if BOTH conditions are met: - // 1. File has type annotations (# typed: comment) - // 2. Workspace has sorbet/config file - // This prevents spawning broken Sorbet containers that spin at 100% CPU - let sorbet_config_dir = if has_sorbet_type_annotation(path) { - find_sorbet_config_dir(path) - } else { - None - }; - let is_sorbet = sorbet_config_dir.is_some(); - - return Ok(SupportedLanguages::from_ruby_version_with_config( - &version, - is_sorbet, - sorbet_config_dir, - )); - } - - workspace_path = parent; - - // Stop at root directory - if parent.parent().is_none() { - break; - } - } - - // Fallback to default version if no workspace markers found - Ok(SupportedLanguages::ruby_default()) - } + ext if RUBY_EXTENSIONS.contains(&ext) => Ok(detect_ruby_language(Path::new(file_path))), _ => Err(LspError::UnsupportedFileType(file_path.to_string())), } } diff --git a/crates/proxy/src/handlers/lsp_ws.rs b/crates/proxy/src/handlers/lsp_ws.rs index 990cdbcc..ba544520 100644 --- a/crates/proxy/src/handlers/lsp_ws.rs +++ b/crates/proxy/src/handlers/lsp_ws.rs @@ -145,9 +145,13 @@ async fn handle_client_text_message( let document_uri = extract_document_uri(&json_rpc_msg).ok_or("Could not extract document URI from message")?; - // Convert file:// URI to path and detect language + // Convert file:// URI to path let file_path = uri_to_file_path(&document_uri)?; - let language = detect_language(&file_path) + + // Convert host path to container path for language detection + // The proxy only has access to the workspace at /mnt/workspace, not the host path + let container_path = host_path_to_container_path(&data.orchestrator, &file_path).await?; + let language = detect_language(&container_path) .map_err(|e| format!("Failed to detect language for {}: {}", file_path, e))?; debug!( @@ -178,6 +182,27 @@ fn uri_to_file_path(uri: &str) -> Result, + host_path: &str, +) -> Result> { + let host_workspace = orchestrator + .get_host_workspace_path() + .await + .ok_or_else(|| "Failed to get host workspace path".to_string())?; + + if let Some(relative_path) = host_path.strip_prefix(&host_workspace) { + Ok(format!("/mnt/workspace{}", relative_path)) + } else { + Err(format!( + "Path {} is not within workspace {}", + host_path, host_workspace + ) + .into()) + } +} + /// Ensure a container connection exists for the given language and return the sink async fn ensure_container_connection<'a>( language: SupportedLanguages, From 51ad14ad3bd57a37969e849faee430af21b0577e Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Tue, 20 Jan 2026 15:44:36 +0100 Subject: [PATCH 75/92] Keep languages sorted alphabetically --- scripts/test-all-endpoints.sh | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/scripts/test-all-endpoints.sh b/scripts/test-all-endpoints.sh index 18dc8ebb..63f76256 100755 --- a/scripts/test-all-endpoints.sh +++ b/scripts/test-all-endpoints.sh @@ -122,17 +122,16 @@ trap cleanup EXIT INT TERM # Language configurations # Format: language_key test_file symbol_name symbol_line symbol_char health_key LANGUAGE_CONFIGS=" -python|main.py|main|14|4|python -typescript|src/main.ts|main|5|6|typescript_javascript -javascript|src/main.ts|main|5|6|typescript_javascript -rust|src/main.rs|main|10|3|rust -go|main.go|main|7|5|golang -java|Main.java|main|5|23|java cpp|astar_search.cpp|main|2|4|cpp csharp|Program.cs|Main|4|20|csharp +go|main.go|main|7|5|golang +java|Main.java|main|5|23|java +javascript|src/main.ts|main|5|6|typescript_javascript php|AStar.php|findPathTo|26|20|php +python|main.py|main|14|4|python ruby|main.rb|main|35|4|ruby_3_4_4 -ruby-sorbet|user_service.rb|create_user|15|6|ruby_sorbet_3_4_4 +rust|src/main.rs|main|10|3|rust +typescript|src/main.ts|main|5|6|typescript_javascript " # Deep validation for find-referenced-symbols (ast-grep backed) @@ -157,18 +156,18 @@ ruby-sorbet|user_service.rb|create_user|15|6|ruby_sorbet_3_4_4 # Note: All languages have identifier and symbol rules, but find-referenced-symbols # specifically requires reference rules to find symbol usages within a method body. FIND_REF_TESTS=" -python|main.py|14|4|1|AStarGraph -typescript|src/astar.ts|60|12|2|isInBounds,isWalkable csharp|AStar.cs|23|27|1|AddNeighborsToOpenList php|AStar.php|26|20|1|addNeighborsToOpenList +python|main.py|14|4|1|AStarGraph +typescript|src/astar.ts|60|12|2|isInBounds,isWalkable " # Tests that are not working (commented out - need ast-grep reference rules) -# ruby|search.rb|31|15|1|initialize_search +# clangd|astar_search.cpp|??|??|1|?? # golang|golang_astar/astar.go|??|??|1|?? -# rust|src/astar.rs|??|??|1|?? # java|AStar.java|39|22|1|?? -# clangd|astar_search.cpp|??|??|1|?? +# ruby|search.rb|31|15|1|initialize_search +# rust|src/astar.rs|??|??|1|?? #" test_http_endpoint() { @@ -251,10 +250,11 @@ test_ws_endpoint() { echo -n " Testing $test_name... " # Build curl command with timeout - local curl_cmd=("websocat" "-q1" "ws${BASE_URL#http}$endpoint") + local ws_cmd=("websocat" "-q1" "ws${BASE_URL#http}$endpoint") + echo "${ws_cmd[@]}" - # Execute request (curl has built-in timeout via --max-time) - if response=$(echo "$data" | timeout 30 "${curl_cmd[@]}" 2>&1); then + # Execute request + if response=$(echo "$data" | timeout 30 "${ws_cmd[@]}" 2>&1); then local body="$response" # Validate JSON structure @@ -584,7 +584,7 @@ while IFS='|' read -r lang test_file symbol_name symbol_line symbol_char health_ "jq -e 'type == \"object\"' > /dev/null" # Find Definition (assert selected identifier and at least one definition) - test_ws_endpoint "LSP-WS GoTo Definition ($lang)" \ + test_ws_endpoint "LSP GoTo Definition ($lang)" \ "/lsp/ws" \ "{\"jsonrpc\":\"2.0\",\"id\":\"$TOTAL_TESTS\",\"method\":\"textDocument/definition\",\"params\":{\"textDocument\":{\"uri\":\"$test_uri\"},\"position\":{\"line\":$symbol_line,\"character\":$symbol_char}}}" \ "jq -e '.result | if type == \"array\" then . else [.] end | length > 0' > /dev/null" From 5bcf35f53bf0b7d3e3b6e593840da9b9a101afa6 Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Tue, 20 Jan 2026 15:49:36 +0100 Subject: [PATCH 76/92] Cleanup debug statement --- scripts/test-all-endpoints.sh | 1 - 1 file changed, 1 deletion(-) diff --git a/scripts/test-all-endpoints.sh b/scripts/test-all-endpoints.sh index 63f76256..4cb32a66 100755 --- a/scripts/test-all-endpoints.sh +++ b/scripts/test-all-endpoints.sh @@ -251,7 +251,6 @@ test_ws_endpoint() { # Build curl command with timeout local ws_cmd=("websocat" "-q1" "ws${BASE_URL#http}$endpoint") - echo "${ws_cmd[@]}" # Execute request if response=$(echo "$data" | timeout 30 "${ws_cmd[@]}" 2>&1); then From d24756ceefd84ef1d2d36b5ea59b4d263643119c Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Tue, 20 Jan 2026 15:49:48 +0100 Subject: [PATCH 77/92] Update changelog text --- CHANGELOG.services.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/CHANGELOG.services.md b/CHANGELOG.services.md index 7476f7e7..a4ce5c16 100644 --- a/CHANGELOG.services.md +++ b/CHANGELOG.services.md @@ -9,8 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Support for the proxy to act as a proper LSP server via the `/lsp/ws` - WebSocket endpoint. +- A new `/lsp/ws` WebSocket endpoints exposes the proxy as a proper LSP server. ## [0.4.9] - 2025-01-15 From a03271bc79741405c870707396a7eb7b1e6a64b5 Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Tue, 20 Jan 2026 16:08:44 +0100 Subject: [PATCH 78/92] Improve language display in log messages --- crates/proxy/src/handlers/container_proxy.rs | 6 +++--- crates/proxy/src/handlers/lsp_ws.rs | 10 +++++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/crates/proxy/src/handlers/container_proxy.rs b/crates/proxy/src/handlers/container_proxy.rs index aab68cd5..eb7409a0 100644 --- a/crates/proxy/src/handlers/container_proxy.rs +++ b/crates/proxy/src/handlers/container_proxy.rs @@ -22,20 +22,20 @@ pub async fn get_container_api_client( } // Spawn new container - info!("Spawning container for {:?}", language); + info!("Spawning container for {}", language); match orchestrator .spawn_language_container(language.clone()) .await { Ok(container_info) => { info!( - "Container spawned for {:?}: {}", + "Container spawned for {}: {}", language, container_info.endpoint ); Ok(ContainerApiClient::new(&container_info.endpoint)) } Err(e) => { - error!("Failed to spawn container for {:?}: {}", language, e); + error!("Failed to spawn container for {}: {}", language, e); Err(format!("Failed to spawn container: {}", e)) } } diff --git a/crates/proxy/src/handlers/lsp_ws.rs b/crates/proxy/src/handlers/lsp_ws.rs index ba544520..d7c61081 100644 --- a/crates/proxy/src/handlers/lsp_ws.rs +++ b/crates/proxy/src/handlers/lsp_ws.rs @@ -155,7 +155,7 @@ async fn handle_client_text_message( .map_err(|e| format!("Failed to detect language for {}: {}", file_path, e))?; debug!( - "Routing message for file {} (language: {:?})", + "Routing message for file {} (language: {})", file_path, language ); @@ -229,7 +229,7 @@ async fn ensure_container_connection<'a>( let (container_ws, _) = tokio_tungstenite::connect_async(&container_ws_url).await?; - info!("Connected to container WebSocket for {:?}", language); + info!("Connected to container WebSocket for {}", language); // Split WebSocket into read and write halves let (sink, stream) = container_ws.split(); @@ -259,7 +259,7 @@ fn spawn_container_reader( while let Some(msg_result) = stream.next().await { match msg_result { Ok(TungsteniteMessage::Text(text)) => { - debug!("Container -> Client from {:?}: {}", language, text); + debug!("Container -> Client from {}: {}", language, text); if let Err(e) = forward_container_message_to_client( text, @@ -273,12 +273,12 @@ fn spawn_container_reader( } } Ok(TungsteniteMessage::Close(_)) => { - debug!("Container WebSocket closed for {:?}", language); + debug!("Container WebSocket closed for {}", language); break; } Err(e) => { error!( - "Error reading from container WebSocket for {:?}: {}", + "Error reading from container WebSocket for {}: {}", language, e ); break; From fe0be0b3e7ae532c4743eace458afad3920a21cd Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Tue, 20 Jan 2026 16:08:58 +0100 Subject: [PATCH 79/92] Add instructions for debugging test failures --- DEVELOPMENT.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 04497615..eaadad7e 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -217,6 +217,14 @@ Follow these steps to add a new language: ## Debugging +**Run endpoint tests without cleaning up containers:** + +```bash +scripts/test-all-endpoints.sh --no-cleanup +``` + +This leaves all containers running after the tests and allows inspecting the logs for troubleshooting. + **View service logs:** ```bash From d709be93e25bdb7b2c33ce8e6826123c91c08f56 Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Tue, 20 Jan 2026 16:31:57 +0100 Subject: [PATCH 80/92] Fix LSP endpoint test --- scripts/test-all-endpoints.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/test-all-endpoints.sh b/scripts/test-all-endpoints.sh index 4cb32a66..75cd6d4c 100755 --- a/scripts/test-all-endpoints.sh +++ b/scripts/test-all-endpoints.sh @@ -583,10 +583,10 @@ while IFS='|' read -r lang test_file symbol_name symbol_line symbol_char health_ "jq -e 'type == \"object\"' > /dev/null" # Find Definition (assert selected identifier and at least one definition) - test_ws_endpoint "LSP GoTo Definition ($lang)" \ + test_ws_endpoint "LSP textDocument/references ($lang)" \ "/lsp/ws" \ - "{\"jsonrpc\":\"2.0\",\"id\":\"$TOTAL_TESTS\",\"method\":\"textDocument/definition\",\"params\":{\"textDocument\":{\"uri\":\"$test_uri\"},\"position\":{\"line\":$symbol_line,\"character\":$symbol_char}}}" \ - "jq -e '.result | if type == \"array\" then . else [.] end | length > 0' > /dev/null" + "{\"jsonrpc\":\"2.0\",\"id\":\"$TOTAL_TESTS\",\"method\":\"textDocument/references\",\"params\":{\"textDocument\":{\"uri\":\"$test_uri\"},\"position\":{\"line\":$symbol_line,\"character\":$symbol_char},\"context\":{\"includeDeclaration\":true}}}" \ + "jq -e '.result | type == \"array\"' > /dev/null" echo done <<< "$LANGUAGE_CONFIGS" From e2fedb2537aaafa6e6eab351b95d7809079e3f33 Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Tue, 20 Jan 2026 16:41:11 +0100 Subject: [PATCH 81/92] Fix findImage logic --- clients/typescript/src/proxy.ts | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/clients/typescript/src/proxy.ts b/clients/typescript/src/proxy.ts index 8a8ea687..1aeb350e 100644 --- a/clients/typescript/src/proxy.ts +++ b/clients/typescript/src/proxy.ts @@ -55,17 +55,13 @@ async function findImage( sudo?: boolean, stream?: boolean, ): Promise> { - const registryImage = image.includes("/") - ? image - : (() => { - // Check if local image exists - if (imageExists(image, sudo)) { - return image; - } + // Check if local image exists + if (imageExists(image, sudo)) { + return ok(image); + } - // Build the registry-prefixed image name - return `${registry}/${image}`; - })(); + // Build the registry-prefixed image name + const registryImage = image.includes("/") ? image : `${registry}/${image}`; // Try to pull from registry const pullRes = await pull(registryImage, sudo, stream); From 203322af3f51c9801d8e222e3c221cf90f511091 Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Tue, 20 Jan 2026 16:59:11 +0100 Subject: [PATCH 82/92] Update changelogs --- CHANGELOG.services.md | 2 +- clients/typescript/CHANGELOG.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.services.md b/CHANGELOG.services.md index a4ce5c16..0318b3a4 100644 --- a/CHANGELOG.services.md +++ b/CHANGELOG.services.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [0.5.0] - 2025-01-20 ### Added diff --git a/clients/typescript/CHANGELOG.md b/clients/typescript/CHANGELOG.md index 06201629..98607e41 100644 --- a/clients/typescript/CHANGELOG.md +++ b/clients/typescript/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [0.5.0] - 2025-01-20 ### Added From 9df9a9ff6792fc7501c69dad89195e2a00e9fa3e Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Tue, 20 Jan 2026 17:03:11 +0100 Subject: [PATCH 83/92] Remove agent instructions --- AGENTS.md | 38 -------------------------------------- CLAUDE.md | 1 - 2 files changed, 39 deletions(-) delete mode 100644 AGENTS.md delete mode 120000 CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 96fbaf90..00000000 --- a/AGENTS.md +++ /dev/null @@ -1,38 +0,0 @@ -# Coding Agent Instructions for nuanced-dev/lsp - -This repo contains a containerized LSP implementation. - -Main code directories: - -- `clients`: clients for different languages -- `crates`: the main Rust code for the proxy (front-end) and the wrapper (per-language back-end) -- `dockerfiles`: the Dockerfiles for the Rust containers and language-specific containers - -## Rust crates - -The code is managed with Cargo. A typical workflow: - -- Verify code with `cargo build` -- Once done, format code with `cargo fmt` - -Do not run tests unless explicitly asked! - -## TypeScript client - -All the code is typed. Ensure the generated code is also typed. It should not be necessary to use `any` or `unknown` types. - -The code is managed with NPM. A typical workflow: - -- Verify code with `npm run build` -- Once done, format and lint with `npm run lint:fix` - -Add new dependencies through NPM. - -- Install a runtime dependency with `npm install PKG_NAME` -- Install a dev dependency with `npm install --save-dev PKG_NAME` - -If there are module import errors, ensure all dependencies are installed first, before modifying the code. - -- Install all dependencies with `npm install` - -Do not run tests unless explicitly asked! diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 120000 index 47dc3e3d..00000000 --- a/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -AGENTS.md \ No newline at end of file From cc93568949ce7691fb5944da9ecc6d563ee7715c Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Tue, 20 Jan 2026 17:13:16 +0100 Subject: [PATCH 84/92] Review VSCode client --- clients/vscode/README.md | 18 ++++++++++-------- clients/vscode/package.json | 12 ++++++------ clients/vscode/scripts/code-with-nuanced-lsp | 9 +++++++++ scripts/code-with-lsp | 8 -------- 4 files changed, 25 insertions(+), 22 deletions(-) create mode 100755 clients/vscode/scripts/code-with-nuanced-lsp delete mode 100755 scripts/code-with-lsp diff --git a/clients/vscode/README.md b/clients/vscode/README.md index 2027d8b8..c44f9a33 100644 --- a/clients/vscode/README.md +++ b/clients/vscode/README.md @@ -2,6 +2,13 @@ VSCode extension for Nuanced LSP server providing advanced language features. +_This is not meant to be a full-featured language plugin! Merely a way to showcase the LSP capabilities and do interactive debugging._ + +## Requirements + +- VSCode 1.75.0 or higher +- `nuanced-lsp` command available in your PATH + ## Development ### Building @@ -11,16 +18,16 @@ npm install npm run build ``` -### Running from Command Line +## Running from Command Line To start VSCode with the extension enabled for development: ```bash # From the vscode directory -code --extensionDevelopmentPath="$(pwd)" /path/to/your/workspace +scripts/code-with-nuanced-lsp ``` -### Packaging +## Packaging To create a VSIX package for distribution: @@ -33,8 +40,3 @@ This will create a `.vsix` file that can be installed in VSCode via: ```bash code --install-extension nuanced-lsp-vscode-0.1.0.vsix ``` - -## Requirements - -- VSCode 1.75.0 or higher -- `nuanced-lsp` command available in your PATH diff --git a/clients/vscode/package.json b/clients/vscode/package.json index ee1f1524..eca2bec1 100644 --- a/clients/vscode/package.json +++ b/clients/vscode/package.json @@ -13,16 +13,16 @@ ], "main": "./dist/extension.js", "activationEvents": [ - "onLanguage:python", - "onLanguage:typescript", - "onLanguage:javascript", - "onLanguage:rust", "onLanguage:cpp", "onLanguage:csharp", - "onLanguage:java", "onLanguage:go", + "onLanguage:java", + "onLanguage:javascript", "onLanguage:php", - "onLanguage:ruby" + "onLanguage:python", + "onLanguage:ruby", + "onLanguage:rust", + "onLanguage:typescript" ], "contributes": { "commands": [ diff --git a/clients/vscode/scripts/code-with-nuanced-lsp b/clients/vscode/scripts/code-with-nuanced-lsp new file mode 100755 index 00000000..68f3acd7 --- /dev/null +++ b/clients/vscode/scripts/code-with-nuanced-lsp @@ -0,0 +1,9 @@ +#!/usr/bin/env bash + +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$(realpath "${BASH_SOURCE[0]}")")" && pwd)" + +VSCODE_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +exec code --extensionDevelopmentPath="$VSCODE_DIR" "$@" diff --git a/scripts/code-with-lsp b/scripts/code-with-lsp deleted file mode 100755 index 7eb00031..00000000 --- a/scripts/code-with-lsp +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/env bash - -set -eu - -script_dir="$(dirname "$0")" -vscode_dir="$(realpath "$script_dir/../clients/vscode")" - -exec code --extensionDevelopmentPath="$vscode_dir" "$@" From e694719c678178c751d813079b009753d1ab400d Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Tue, 20 Jan 2026 17:14:05 +0100 Subject: [PATCH 85/92] Fix rename bug --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c3abe0c8..826543f0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -59,7 +59,7 @@ jobs: # This gives us reproducible builds while still caching Rust compilation. - name: Build service images run: | - ./scripts/build-service-images.sh \ + ./scripts/build-images.sh \ --all-services --service-tag=${{ env.SERVICE_TAG }} \ --language-tag=${{ env.LANGUAGE_TAG }} \ --cache=gha \ From 8d7dd47632ba1f81fb9384431fa7531d7f2c775b Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Tue, 20 Jan 2026 17:19:25 +0100 Subject: [PATCH 86/92] Workflow fix --- .github/workflows/test.yml | 36 ++++++++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 826543f0..b0a30a6a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -198,7 +198,9 @@ jobs: fail-fast: false matrix: node-version: [18.x, 20.x, 22.x, 24.x] - + defaults: + run: + working-directory: clients/typescript steps: - name: Checkout uses: actions/checkout@v6 @@ -217,11 +219,14 @@ jobs: - name: Lint run: npm run lint - test-typescript-client: + typescript-client-test: runs-on: ubuntu-latest needs: - typescript-client-check - build-service-images + defaults: + run: + working-directory: clients/typescript steps: - name: Checkout uses: actions/checkout@v6 @@ -278,3 +283,30 @@ jobs: env: NUANCED_LSP_TIMEOUT: 240 FAIL_FAST: 1 + + vscode-client-check: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node-version: [18.x, 20.x, 22.x, 24.x] + defaults: + run: + working-directory: clients/vscode + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v6 + with: + node-version: ${{ matrix.node-version }} + + - name: Install dependencies + run: npm install + + - name: Build + run: npm run build + + - name: Lint + run: npm run lint From 73ee66f7429820ccf8b2df23eb860208aafe93b6 Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Tue, 20 Jan 2026 17:54:33 +0100 Subject: [PATCH 87/92] Fix linting config --- .../{eslint.config.cjs => eslint.config.mjs} | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) rename clients/vscode/{eslint.config.cjs => eslint.config.mjs} (79%) diff --git a/clients/vscode/eslint.config.cjs b/clients/vscode/eslint.config.mjs similarity index 79% rename from clients/vscode/eslint.config.cjs rename to clients/vscode/eslint.config.mjs index 2559c33e..5593c85d 100644 --- a/clients/vscode/eslint.config.cjs +++ b/clients/vscode/eslint.config.mjs @@ -1,10 +1,14 @@ -const js = require("@eslint/js"); -const typescript = require("typescript-eslint"); -const prettierConfigRecommended = require("eslint-plugin-prettier/recommended"); -const importPlugin = require("eslint-plugin-import"); -const stylistic = require("@stylistic/eslint-plugin"); +import { fileURLToPath } from "url"; +import { dirname } from "path"; +import js from "@eslint/js"; +import typescript from "typescript-eslint"; +import prettierConfigRecommended from "eslint-plugin-prettier/recommended"; +import importPlugin from "eslint-plugin-import"; +import stylistic from "@stylistic/eslint-plugin"; -module.exports = [ +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export default [ js.configs.recommended, ...typescript.configs.recommended, prettierConfigRecommended, @@ -68,6 +72,6 @@ module.exports = [ }, }, { - ignores: ["dist/", "*.config.cjs", "node_modules/"], + ignores: ["*.config.cjs", "*.config.mjs", "dist/", "node_modules/"], }, ]; From d6596611d0438fba28f58d93e0bc64f58ace146d Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Tue, 20 Jan 2026 18:20:09 +0100 Subject: [PATCH 88/92] Make loading service images more robust --- .github/workflows/test.yml | 43 +++++++++++++++++++++----------------- 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b0a30a6a..2e413505 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -94,13 +94,14 @@ jobs: with: cache-on-failure: true - - uses: actions/download-artifact@v6 + - name: Download service images + id: download-service-images + uses: actions/download-artifact@v6 with: name: service-images - path: build-artifacts - name: Load service images - run: docker load -i build-artifacts/service-images.tar + run: docker load -i ${{ steps.download-service-images.outputs.download-path }} - name: Pre-pull Python language image from GHCR run: docker pull ghcr.io/nuanced-dev/nuanced-lsp-python:${{ env.LANGUAGE_TAG }} @@ -117,13 +118,14 @@ jobs: steps: - uses: actions/checkout@v6 - - uses: actions/download-artifact@v6 + - name: Download service images + id: download-service-images + uses: actions/download-artifact@v6 with: name: service-images - path: build-artifacts - - name: Load images - run: docker load -i build-artifacts/service-images.tar + - name: Load service images + run: docker load -i ${{ steps.download-service-images.outputs.download-path }} - name: Pre-pull Python language image from GHCR run: docker pull ghcr.io/nuanced-dev/nuanced-lsp-python:${{ env.LANGUAGE_TAG }} @@ -137,13 +139,14 @@ jobs: steps: - uses: actions/checkout@v6 - - uses: actions/download-artifact@v6 + - name: Download service images + id: download-service-images + uses: actions/download-artifact@v6 with: name: service-images - path: build-artifacts - - name: Load images - run: docker load -i build-artifacts/service-images.tar + - name: Load service images + run: docker load -i ${{ steps.download-service-images.outputs.download-path }} - name: Pre-pull Python language image from GHCR run: docker pull ghcr.io/nuanced-dev/nuanced-lsp-python:${{ env.LANGUAGE_TAG }} @@ -165,13 +168,14 @@ jobs: rename-to: websocat chmod: 0755 - - uses: actions/download-artifact@v6 + - name: Download service images + id: download-service-images + uses: actions/download-artifact@v6 with: name: service-images - path: build-artifacts - - name: Load images - run: docker load -i build-artifacts/service-images.tar + - name: Load service images + run: docker load -i ${{ steps.download-service-images.outputs.download-path }} - name: Pre-pull all language images from GHCR run: | @@ -242,13 +246,14 @@ jobs: - name: Build run: npm run build - - uses: actions/download-artifact@v6 + - name: Download service images + id: download-service-images + uses: actions/download-artifact@v6 with: name: service-images - path: build-artifacts - - name: Load images - run: docker load -i build-artifacts/service-images.tar + - name: Load service images + run: docker load -i ${{ steps.download-service-images.outputs.download-path }} - name: Pre-pull all language images from GHCR run: | From 71c8c6e116f3008a3b79dd43dd80109ea827c697 Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Tue, 20 Jan 2026 18:32:18 +0100 Subject: [PATCH 89/92] Install websocat with cargo, which is hopefully more robust than detecting the release with the right architecture --- .github/workflows/test.yml | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2e413505..8275b26d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -158,15 +158,17 @@ jobs: runs-on: ubuntu-latest needs: build-service-images steps: - - uses: actions/checkout@v6 + - name: Checkout + uses: actions/checkout@v6 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Show Rust toolchain + run: rustc --version && cargo --version - name: Install websocat - uses: jaxxstorm/action-install-gh-release@v1.10.0 - with: - repo: vi/websocat - tag: v1.14.0 - rename-to: websocat - chmod: 0755 + run: cargo install websocat - name: Download service images id: download-service-images From a3ff8b0aa4b9d910b410815a917d63cf6996f441 Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Tue, 20 Jan 2026 18:32:26 +0100 Subject: [PATCH 90/92] Add step names --- .github/workflows/language-images-release.yml | 6 ++- .github/workflows/service-images-release.yml | 9 ++-- .github/workflows/test.yml | 43 ++++++++++++------- 3 files changed, 38 insertions(+), 20 deletions(-) diff --git a/.github/workflows/language-images-release.yml b/.github/workflows/language-images-release.yml index 52ead5bb..3354ba7d 100644 --- a/.github/workflows/language-images-release.yml +++ b/.github/workflows/language-images-release.yml @@ -16,11 +16,13 @@ jobs: build-and-publish: runs-on: ubuntu-latest-large steps: - - uses: actions/checkout@v6 + - name: Checkout + uses: actions/checkout@v6 - uses: docker/setup-qemu-action@v3 - - uses: docker/setup-buildx-action@v3 + - name: Setup Docker buildx + uses: docker/setup-buildx-action@v3 with: driver-opts: network=host diff --git a/.github/workflows/service-images-release.yml b/.github/workflows/service-images-release.yml index d21ce662..3f747804 100644 --- a/.github/workflows/service-images-release.yml +++ b/.github/workflows/service-images-release.yml @@ -16,13 +16,16 @@ jobs: build-and-publish: runs-on: ubuntu-latest-large steps: - - uses: actions/checkout@v6 + - name: Checkout + uses: actions/checkout@v6 - - uses: dtolnay/rust-toolchain@stable + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable - uses: docker/setup-qemu-action@v3 - - uses: docker/setup-buildx-action@v3 + - name: Setup Docker buildx + uses: docker/setup-buildx-action@v3 with: driver-opts: network=host diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8275b26d..a09c9142 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -27,32 +27,38 @@ jobs: service-check: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 - - uses: dtolnay/rust-toolchain@stable + - name: Checkout + uses: actions/checkout@v6 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable - name: Show Rust toolchain run: rustc --version && cargo --version - - uses: Swatinem/rust-cache@v2 + - name: Setup Rust build cache + uses: Swatinem/rust-cache@v2 with: cache-on-failure: true - - name: Run Rust check + - name: Build run: cargo build --locked --workspace --all-targets --all-features env: SERVICE_IMAGE_VERSION: ${{ env.SERVICE_TAG }} LANGUAGE_IMAGE_VERSION: ${{ env.LANGUAGE_TAG }} - - name: Run Rust fmt + - name: Check formatting run: cargo fmt --check build-service-images: runs-on: ubuntu-latest needs: service-check steps: - - uses: actions/checkout@v6 + - name: Checkout + uses: actions/checkout@v6 - - uses: docker/setup-buildx-action@v3 + - name: Setup Docker buildx + uses: docker/setup-buildx-action@v3 # Build without Docker cache to ensure fresh Docker layers, but use GitHub Actions # cache backend for BuildKit cache mounts (Cargo registry and build artifacts). @@ -72,25 +78,30 @@ jobs: nuanced-lsp-proxy:${{ env.SERVICE_TAG }} \ nuanced-lsp-wrapper:${{ env.SERVICE_TAG }} \ nuanced-lsp-watchdog:${{ env.SERVICE_TAG }} \ - -o build-artifacts/service-images.tar + -o service-images.tar - - uses: actions/upload-artifact@v6 + - name: Upload service images + uses: actions/upload-artifact@v6 with: name: service-images - path: build-artifacts/service-images.tar + path: service-images.tar retention-days: 1 service-test-rust: runs-on: ubuntu-latest needs: build-service-images steps: - - uses: actions/checkout@v6 - - uses: dtolnay/rust-toolchain@stable + - name: Checkout + uses: actions/checkout@v6 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable - name: Show Rust toolchain run: rustc --version && cargo --version - - uses: Swatinem/rust-cache@v2 + - name: Setup Rust build cache + uses: Swatinem/rust-cache@v2 with: cache-on-failure: true @@ -116,7 +127,8 @@ jobs: runs-on: ubuntu-latest needs: build-service-images steps: - - uses: actions/checkout@v6 + - name: Checkout + uses: actions/checkout@v6 - name: Download service images id: download-service-images @@ -137,7 +149,8 @@ jobs: runs-on: ubuntu-latest needs: build-service-images steps: - - uses: actions/checkout@v6 + - name: Checkout + uses: actions/checkout@v6 - name: Download service images id: download-service-images From 4f495e4b49e12ca9ebfd575ef842d6a6d8d6f882 Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Tue, 20 Jan 2026 18:55:37 +0100 Subject: [PATCH 91/92] Add archive name to download path --- .github/workflows/test.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a09c9142..a6a1d958 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -112,7 +112,7 @@ jobs: name: service-images - name: Load service images - run: docker load -i ${{ steps.download-service-images.outputs.download-path }} + run: docker load -i ${{ steps.download-service-images.outputs.download-path }}/service-images.tar - name: Pre-pull Python language image from GHCR run: docker pull ghcr.io/nuanced-dev/nuanced-lsp-python:${{ env.LANGUAGE_TAG }} @@ -137,7 +137,7 @@ jobs: name: service-images - name: Load service images - run: docker load -i ${{ steps.download-service-images.outputs.download-path }} + run: docker load -i ${{ steps.download-service-images.outputs.download-path }}/service-images.tar - name: Pre-pull Python language image from GHCR run: docker pull ghcr.io/nuanced-dev/nuanced-lsp-python:${{ env.LANGUAGE_TAG }} @@ -159,7 +159,7 @@ jobs: name: service-images - name: Load service images - run: docker load -i ${{ steps.download-service-images.outputs.download-path }} + run: docker load -i ${{ steps.download-service-images.outputs.download-path }}/service-images.tar - name: Pre-pull Python language image from GHCR run: docker pull ghcr.io/nuanced-dev/nuanced-lsp-python:${{ env.LANGUAGE_TAG }} @@ -190,7 +190,7 @@ jobs: name: service-images - name: Load service images - run: docker load -i ${{ steps.download-service-images.outputs.download-path }} + run: docker load -i ${{ steps.download-service-images.outputs.download-path }}/service-images.tar - name: Pre-pull all language images from GHCR run: | @@ -268,7 +268,7 @@ jobs: name: service-images - name: Load service images - run: docker load -i ${{ steps.download-service-images.outputs.download-path }} + run: docker load -i ${{ steps.download-service-images.outputs.download-path }}/service-images.tar - name: Pre-pull all language images from GHCR run: | From 83effeaf7dacbf6b18941bf0e4af409c56dc019e Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Tue, 20 Jan 2026 20:15:37 +0100 Subject: [PATCH 92/92] Make WS endpoint testing more robust --- scripts/test-all-endpoints.sh | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/scripts/test-all-endpoints.sh b/scripts/test-all-endpoints.sh index 75cd6d4c..6a86fd99 100755 --- a/scripts/test-all-endpoints.sh +++ b/scripts/test-all-endpoints.sh @@ -242,18 +242,27 @@ test_http_endpoint() { test_ws_endpoint() { local test_name="$1" local endpoint="$2" - local data="$3" - local validation_check="$4" + local method="$3" + local params="$4" + local validation_check="$5" TOTAL_TESTS=$((TOTAL_TESTS + 1)) echo -n " Testing $test_name... " - # Build curl command with timeout - local ws_cmd=("websocat" "-q1" "ws${BASE_URL#http}$endpoint") + # Build request + local url="ws${BASE_URL#http}$endpoint" + local request="{\"jsonrpc\":\"2.0\",\"id\":\"$TOTAL_TESTS\",\"method\":\"textDocument/references\",\"params\":$params}" # Execute request - if response=$(echo "$data" | timeout 30 "${ws_cmd[@]}" 2>&1); then + # This is a bit tricky because of websocat exit. It can either + # exit when stdin is closed, but will do so before the response is + # received, or it will never exit. Using `timeout` isn't possible, + # because `timeout` exists with an error code. We use sleep to delay + # the closing of stdin. Hopefully 5 seconds is enough, because the + # test will always wait for the full delay, even if the response + # comes earlier. + if response=$((echo "$request" ; sleep 5) | websocat -q --exit-on-eof "$url" | jq -c --unbuffered "select(.id == \"$TOTAL_TESTS\") | .result , halt" 2>&1); then local body="$response" # Validate JSON structure @@ -585,8 +594,9 @@ while IFS='|' read -r lang test_file symbol_name symbol_line symbol_char health_ # Find Definition (assert selected identifier and at least one definition) test_ws_endpoint "LSP textDocument/references ($lang)" \ "/lsp/ws" \ - "{\"jsonrpc\":\"2.0\",\"id\":\"$TOTAL_TESTS\",\"method\":\"textDocument/references\",\"params\":{\"textDocument\":{\"uri\":\"$test_uri\"},\"position\":{\"line\":$symbol_line,\"character\":$symbol_char},\"context\":{\"includeDeclaration\":true}}}" \ - "jq -e '.result | type == \"array\"' > /dev/null" + "textDocument/references" \ + "{\"textDocument\":{\"uri\":\"$test_uri\"},\"position\":{\"line\":$symbol_line,\"character\":$symbol_char},\"context\":{\"includeDeclaration\":true}}" \ + "jq -e 'type == \"array\"' > /dev/null" echo done <<< "$LANGUAGE_CONFIGS"