diff --git a/CHANGELOG.md b/CHANGELOG.md index 36171e7..c120df0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **TSX/JSX diagnostics** — Preserve `typescriptreact`/`javascriptreact` language IDs when deriving mappings from TypeScript/JavaScript server `file_patterns`, fixing JSX parse errors when one server handles both plain and React extensions. - **LSP server requests** — Handle server-to-client requests such as `client/registerCapability`, fixing tsgo timeouts. - **Integration tests** — Add `[workspace]` table to `tests/fixtures/rust_workspace/Cargo.toml` so cargo treats the fixture as a standalone workspace; fixes 8 rust-analyzer integration tests that failed with "Failed to load workspaces." (#118) - **e2e coverage** — Add ra_e2e sub-cases for `get_signature_help`, `go_to_implementation`, `go_to_type_definition`, `get_inlay_hints` (4 LSP 3.17 tools from #124 had no coverage); add `list_resources`, `read_resource`, `subscribe_resource`, `unsubscribe_resource` to `McpClient` and ra_e2e_suite (MCP resources path was entirely untested) (#129, #130) diff --git a/crates/mcpls-core/src/bridge/translator.rs b/crates/mcpls-core/src/bridge/translator.rs index 9f78b1c..38151f2 100644 --- a/crates/mcpls-core/src/bridge/translator.rs +++ b/crates/mcpls-core/src/bridge/translator.rs @@ -531,10 +531,25 @@ impl Translator { /// Get a cloned LSP client for a file path based on language detection. fn get_client_for_file(&self, path: &Path) -> Result { let language_id = detect_language(path, &self.extension_map); - self.lsp_clients - .get(&language_id) - .cloned() - .ok_or(Error::NoServerForLanguage(language_id)) + if let Some(client) = self.lsp_clients.get(&language_id) { + return Ok(client.clone()); + } + + if let Some(server_language_id) = Self::server_language_id_for_document(&language_id) + && let Some(client) = self.lsp_clients.get(server_language_id) + { + return Ok(client.clone()); + } + + Err(Error::NoServerForLanguage(language_id)) + } + + fn server_language_id_for_document(language_id: &str) -> Option<&'static str> { + match language_id { + "javascriptreact" => Some("javascript"), + "typescriptreact" => Some("typescript"), + _ => None, + } } /// Parse and validate a file URI, returning the validated path. @@ -3209,6 +3224,85 @@ mod tests { } } + #[test] + fn test_get_client_for_file_routes_tsx_to_typescript_server() { + let temp_dir = TempDir::new().unwrap(); + let test_file = temp_dir.path().join("component.tsx"); + fs::write(&test_file, "export const Component = () =>
").unwrap(); + + let mut extension_map = HashMap::new(); + extension_map.insert("tsx".to_string(), "typescriptreact".to_string()); + + let mut translator = Translator::new().with_extensions(extension_map); + translator.register_client( + "typescript".to_string(), + LspClient::new(crate::config::LspServerConfig::typescript()), + ); + + let client = translator.get_client_for_file(&test_file).unwrap(); + assert_eq!(client.language_id(), "typescript"); + } + + #[test] + fn test_get_client_for_file_prefers_exact_react_server() { + let temp_dir = TempDir::new().unwrap(); + let test_file = temp_dir.path().join("component.tsx"); + fs::write(&test_file, "export const Component = () =>
").unwrap(); + + let mut extension_map = HashMap::new(); + extension_map.insert("tsx".to_string(), "typescriptreact".to_string()); + + let typescript_react_config = crate::config::LspServerConfig { + language_id: "typescriptreact".to_string(), + command: "typescript-language-server".to_string(), + args: vec!["--stdio".to_string()], + env: HashMap::new(), + file_patterns: vec!["**/*.tsx".to_string()], + initialization_options: None, + timeout_seconds: 30, + heuristics: None, + }; + + let mut translator = Translator::new().with_extensions(extension_map); + translator.register_client( + "typescript".to_string(), + LspClient::new(crate::config::LspServerConfig::typescript()), + ); + translator.register_client( + "typescriptreact".to_string(), + LspClient::new(typescript_react_config), + ); + + let client = translator.get_client_for_file(&test_file).unwrap(); + assert_eq!(client.language_id(), "typescriptreact"); + } + + #[test] + fn test_get_client_for_file_routes_jsx_to_javascript_server() { + let temp_dir = TempDir::new().unwrap(); + let test_file = temp_dir.path().join("component.jsx"); + fs::write(&test_file, "export const Component = () =>
").unwrap(); + + let mut extension_map = HashMap::new(); + extension_map.insert("jsx".to_string(), "javascriptreact".to_string()); + + let javascript_config = crate::config::LspServerConfig { + language_id: "javascript".to_string(), + command: "typescript-language-server".to_string(), + args: vec!["--stdio".to_string()], + env: HashMap::new(), + file_patterns: vec!["**/*.js".to_string(), "**/*.jsx".to_string()], + initialization_options: None, + timeout_seconds: 30, + heuristics: None, + }; + let mut translator = Translator::new().with_extensions(extension_map); + translator.register_client("javascript".to_string(), LspClient::new(javascript_config)); + + let client = translator.get_client_for_file(&test_file).unwrap(); + assert_eq!(client.language_id(), "javascript"); + } + #[tokio::test] async fn test_serve_initializes_translator_with_extensions() { use crate::config::{LanguageExtensionMapping, WorkspaceConfig}; diff --git a/crates/mcpls-core/src/config/mod.rs b/crates/mcpls-core/src/config/mod.rs index 9a77170..25b6d3b 100644 --- a/crates/mcpls-core/src/config/mod.rs +++ b/crates/mcpls-core/src/config/mod.rs @@ -142,6 +142,15 @@ fn extract_extension_from_pattern(pattern: &str) -> Option { } } +fn language_id_for_pattern_extension(server_language_id: &str, extension: &str) -> String { + match (server_language_id, extension) { + ("javascript", "jsx") => "javascriptreact", + ("typescript", "tsx") => "typescriptreact", + _ => server_language_id, + } + .to_string() +} + fn default_position_encodings() -> Vec { vec!["utf-8".to_string(), "utf-16".to_string()] } @@ -295,7 +304,8 @@ impl ServerConfig { for server in &self.lsp_servers { for pattern in &server.file_patterns { if let Some(ext) = extract_extension_from_pattern(pattern) { - map.insert(ext, server.language_id.clone()); + let language_id = language_id_for_pattern_extension(&server.language_id, &ext); + map.insert(ext, language_id); } } } @@ -745,6 +755,48 @@ mod tests { assert_eq!(map.get("h"), Some(&"cpp".to_string())); } + #[test] + fn test_build_effective_extension_map_derives_tsx_language_id() { + let config = ServerConfig { + workspace: WorkspaceConfig::default(), + lsp_servers: vec![LspServerConfig { + language_id: "typescript".to_string(), + command: "tsgo".to_string(), + args: vec!["--lsp".to_string(), "--stdio".to_string()], + env: HashMap::new(), + file_patterns: vec!["**/*.ts".to_string(), "**/*.tsx".to_string()], + initialization_options: None, + timeout_seconds: 30, + heuristics: None, + }], + }; + + let map = config.build_effective_extension_map(); + assert_eq!(map.get("ts"), Some(&"typescript".to_string())); + assert_eq!(map.get("tsx"), Some(&"typescriptreact".to_string())); + } + + #[test] + fn test_build_effective_extension_map_derives_jsx_language_id() { + let config = ServerConfig { + workspace: WorkspaceConfig::default(), + lsp_servers: vec![LspServerConfig { + language_id: "javascript".to_string(), + command: "typescript-language-server".to_string(), + args: vec!["--stdio".to_string()], + env: HashMap::new(), + file_patterns: vec!["**/*.js".to_string(), "**/*.jsx".to_string()], + initialization_options: None, + timeout_seconds: 30, + heuristics: None, + }], + }; + + let map = config.build_effective_extension_map(); + assert_eq!(map.get("js"), Some(&"javascript".to_string())); + assert_eq!(map.get("jsx"), Some(&"javascriptreact".to_string())); + } + #[test] fn test_build_effective_extension_map_ignores_complex_patterns_without_extension() { let config = ServerConfig {