Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
102 changes: 98 additions & 4 deletions crates/mcpls-core/src/bridge/translator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<LspClient> {
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.
Expand Down Expand Up @@ -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 = () => <div />").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 = () => <div />").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 = () => <div />").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};
Expand Down
54 changes: 53 additions & 1 deletion crates/mcpls-core/src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,15 @@ fn extract_extension_from_pattern(pattern: &str) -> Option<String> {
}
}

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<String> {
vec!["utf-8".to_string(), "utf-16".to_string()]
}
Expand Down Expand Up @@ -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);
}
}
}
Expand Down Expand Up @@ -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 {
Expand Down
Loading