diff --git a/CHANGELOG.md b/CHANGELOG.md index c6fed43..55e5bcd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ 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] +### Changed + +- **C/C++ file pattern language detection** - When `lsp_servers[].file_patterns` include simple extensions such as `**/*.c` or `**/*.h`, mcpls now derives extension-to-language mappings from those patterns and overlays them onto the workspace extension map. This changes the default behavior for matching C/C++ files to prefer the configured LSP server language instead of falling back to built-in mappings or `plaintext`. ## [0.3.5] - 2026-03-17 diff --git a/crates/mcpls-core/src/bridge/translator.rs b/crates/mcpls-core/src/bridge/translator.rs index b853472..5a6eb26 100644 --- a/crates/mcpls-core/src/bridge/translator.rs +++ b/crates/mcpls-core/src/bridge/translator.rs @@ -18,10 +18,10 @@ use tokio::time::Duration; use url::Url; use super::state::{ResourceLimits, detect_language}; -use super::{DocumentTracker, NotificationCache}; +use super::{DocumentTracker, LogLevel, MessageType, NotificationCache}; use crate::bridge::encoding::mcp_to_lsp_position; use crate::error::{Error, Result}; -use crate::lsp::{LspClient, LspServer}; +use crate::lsp::{LspClient, LspNotification, LspServer}; /// Translator handles MCP tool calls by converting them to LSP requests. #[derive(Debug)] @@ -103,6 +103,28 @@ impl Translator { &mut self.notification_cache } + /// Store a forwarded LSP notification in the local cache. + pub fn handle_notification(&mut self, notification: LspNotification) { + match notification { + LspNotification::PublishDiagnostics(params) => { + self.notification_cache.store_diagnostics( + ¶ms.uri, + params.version, + params.diagnostics, + ); + } + LspNotification::LogMessage(params) => { + self.notification_cache + .store_log(LogLevel::from(params.typ), params.message); + } + LspNotification::ShowMessage(params) => { + self.notification_cache + .store_message(MessageType::from(params.typ), params.message); + } + LspNotification::Other { .. } => {} + } + } + // TODO: These methods will be implemented in Phase 3-5 // Initialize and shutdown are now handled by LspServer in lifecycle.rs @@ -453,13 +475,27 @@ 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); + let language_id = self.get_language_id_for_file(path); self.lsp_clients .get(&language_id) .cloned() .ok_or(Error::NoServerForLanguage(language_id)) } + /// Resolve the language ID for a file path. + fn get_language_id_for_file(&self, path: &Path) -> String { + detect_language(path, &self.extension_map) + } + + /// Determine if the selected server for a file supports pull diagnostics. + fn supports_pull_diagnostics(&self, path: &Path) -> bool { + let language_id = self.get_language_id_for_file(path); + self.lsp_servers + .get(&language_id) + .and_then(|server| server.capabilities().diagnostic_provider.as_ref()) + .is_some() + } + /// Parse and validate a file URI, returning the validated path. /// /// # Errors @@ -668,13 +704,18 @@ impl Translator { let path = PathBuf::from(&file_path); let validated_path = self.validate_path(&path)?; let client = self.get_client_for_file(&validated_path)?; - let uri = self + let supports_pull_diagnostics = self.supports_pull_diagnostics(&validated_path); + let _uri = self .document_tracker .ensure_open(&validated_path, &client) .await?; + if !supports_pull_diagnostics { + return self.handle_cached_diagnostics(&file_path); + } + let params = lsp_types::DocumentDiagnosticParams { - text_document: TextDocumentIdentifier { uri }, + text_document: TextDocumentIdentifier { uri: _uri }, identifier: None, previous_result_id: None, work_done_progress_params: WorkDoneProgressParams::default(), @@ -682,9 +723,16 @@ impl Translator { }; let timeout_duration = Duration::from_secs(30); - let response: lsp_types::DocumentDiagnosticReportResult = client + let response: lsp_types::DocumentDiagnosticReportResult = match client .request("textDocument/diagnostic", params, timeout_duration) - .await?; + .await + { + Ok(response) => response, + Err(Error::LspServerError { code: -32601, .. }) => { + return self.handle_cached_diagnostics(&file_path); + } + Err(error) => return Err(error), + }; let diagnostics = match response { lsp_types::DocumentDiagnosticReportResult::Report(report) => match report { @@ -1577,11 +1625,19 @@ fn convert_code_action(action: lsp_types::CodeAction) -> CodeAction { #[allow(clippy::unwrap_used)] mod tests { use std::fs; + use std::process::Stdio; + use lsp_types::{ + Diagnostic as LspDiagnostic, DiagnosticSeverity as LspDiagnosticSeverity, Position, + PositionEncodingKind, PublishDiagnosticsParams, Range as LspRange, ServerCapabilities, Uri, + }; use tempfile::TempDir; + use tokio::process::Command; use url::Url; use super::*; + use crate::config::LspServerConfig; + use crate::lsp::LspTransport; #[test] fn test_translator_new() { @@ -2158,6 +2214,146 @@ mod tests { assert_eq!(diags.diagnostics.len(), 0); } + fn create_test_client(language_id: &str) -> LspClient { + let mock_stdin = Command::new("cat") + .stdin(Stdio::piped()) + .spawn() + .unwrap() + .stdin + .take() + .unwrap(); + + let mock_stdout = Command::new("tail") + .arg("-f") + .arg("/dev/null") + .stdout(Stdio::piped()) + .spawn() + .unwrap() + .stdout + .take() + .unwrap(); + + let transport = LspTransport::new(mock_stdin, mock_stdout); + let mut config = LspServerConfig::clangd(); + config.language_id = language_id.to_string(); + LspClient::from_transport(config, transport) + } + + #[test] + fn test_handle_notification_stores_diagnostics() { + let mut translator = Translator::new(); + let uri: Uri = "file:///workspace/test.cpp".parse().unwrap(); + let diagnostic = LspDiagnostic { + range: LspRange { + start: Position { + line: 0, + character: 0, + }, + end: Position { + line: 0, + character: 3, + }, + }, + severity: Some(LspDiagnosticSeverity::ERROR), + message: "test diagnostic".to_string(), + ..Default::default() + }; + + translator.handle_notification(LspNotification::PublishDiagnostics( + PublishDiagnosticsParams { + uri: uri.clone(), + version: Some(1), + diagnostics: vec![diagnostic], + }, + )); + + let cached = translator.notification_cache().get_diagnostics(uri.as_str()).unwrap(); + assert_eq!(cached.diagnostics.len(), 1); + assert_eq!(cached.version, Some(1)); + assert_eq!(cached.diagnostics[0].message, "test diagnostic"); + } + + #[test] + fn test_handle_notification_stores_logs_and_messages() { + let mut translator = Translator::new(); + + translator.handle_notification(LspNotification::LogMessage(lsp_types::LogMessageParams { + typ: lsp_types::MessageType::WARNING, + message: "log entry".to_string(), + })); + translator.handle_notification(LspNotification::ShowMessage( + lsp_types::ShowMessageParams { + typ: lsp_types::MessageType::INFO, + message: "user message".to_string(), + }, + )); + + let logs = translator.notification_cache().get_logs(); + assert_eq!(logs.len(), 1); + assert_eq!(logs[0].level, LogLevel::Warning); + assert_eq!(logs[0].message, "log entry"); + + let messages = translator.notification_cache().get_messages(); + assert_eq!(messages.len(), 1); + assert_eq!(messages[0].message_type, MessageType::Info); + assert_eq!(messages[0].message, "user message"); + } + + #[tokio::test] + async fn test_handle_diagnostics_falls_back_to_cached_when_pull_unsupported() { + let temp_dir = TempDir::new().unwrap(); + let test_file = temp_dir.path().join("test.cpp"); + fs::write(&test_file, "int main() { return 0; }\n").unwrap(); + + let client = create_test_client("cpp"); + let server = LspServer::new_for_tests( + client.clone(), + ServerCapabilities::default(), + PositionEncodingKind::UTF8, + ); + + let mut extension_map = HashMap::new(); + extension_map.insert("cpp".to_string(), "cpp".to_string()); + + let mut translator = Translator::new().with_extensions(extension_map); + translator.set_workspace_roots(vec![temp_dir.path().to_path_buf()]); + translator.register_client("cpp".to_string(), client); + translator.register_server("cpp".to_string(), server); + + let uri = Url::from_file_path(&test_file).unwrap(); + let diagnostic = LspDiagnostic { + range: LspRange { + start: Position { + line: 0, + character: 4, + }, + end: Position { + line: 0, + character: 8, + }, + }, + severity: Some(LspDiagnosticSeverity::WARNING), + message: "cached diagnostic".to_string(), + ..Default::default() + }; + let cached_uri: Uri = uri.as_str().parse().unwrap(); + translator + .notification_cache_mut() + .store_diagnostics(&cached_uri, Some(1), vec![diagnostic]); + + let result = translator + .handle_diagnostics(test_file.to_str().unwrap().to_string()) + .await + .unwrap(); + + assert_eq!(result.diagnostics.len(), 1); + assert_eq!(result.diagnostics[0].message, "cached diagnostic"); + assert!(matches!( + result.diagnostics[0].severity, + DiagnosticSeverity::Warning + )); + } + #[test] fn test_handle_server_logs_with_filter() { use crate::bridge::notifications::LogLevel; @@ -2776,7 +2972,7 @@ mod tests { lsp_servers: vec![], }; - let extension_map = config.workspace.build_extension_map(); + let extension_map = config.build_effective_extension_map(); assert_eq!(extension_map.get("nu"), Some(&"nushell".to_string())); assert_eq!(extension_map.get("rs"), Some(&"rust".to_string())); diff --git a/crates/mcpls-core/src/config/mod.rs b/crates/mcpls-core/src/config/mod.rs index d1ac411..04cadcf 100644 --- a/crates/mcpls-core/src/config/mod.rs +++ b/crates/mcpls-core/src/config/mod.rs @@ -116,6 +116,32 @@ impl WorkspaceConfig { } } +/// Extract a file extension from a glob-like file pattern. +/// +/// Supports common patterns such as `**/*.rs` and `*.h`. +/// Returns `None` for patterns without a simple trailing extension. +fn extract_extension_from_pattern(pattern: &str) -> Option { + let basename = pattern.rsplit('/').next().unwrap_or(pattern); + if basename.starts_with('.') { + return None; + } + + let (_, ext) = pattern.rsplit_once('.')?; + if ext.is_empty() { + return None; + } + + // Keep this conservative: only accept plain extension-like tokens. + if ext + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') + { + Some(ext.to_string()) + } else { + None + } +} + fn default_position_encodings() -> Vec { vec!["utf-8".to_string(), "utf-16".to_string()] } @@ -258,6 +284,25 @@ fn default_language_extensions() -> Vec { } impl ServerConfig { + /// Build the effective extension map used for language detection. + /// + /// Starts with workspace mappings and overlays mappings inferred from + /// configured LSP server `file_patterns`. + #[must_use] + pub fn build_effective_extension_map(&self) -> HashMap { + let mut map = self.workspace.build_extension_map(); + + 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()); + } + } + } + + map + } + /// Load configuration from the default path. /// /// Default paths checked in order: @@ -656,6 +701,71 @@ mod tests { assert_eq!(map.get("unknown"), None); } + #[test] + fn test_extract_extension_from_pattern_empty_string() { + assert_eq!(extract_extension_from_pattern(""), None); + } + + #[test] + fn test_extract_extension_from_pattern_without_dot() { + assert_eq!(extract_extension_from_pattern("**/*"), None); + } + + #[test] + fn test_extract_extension_from_pattern_dotfile() { + assert_eq!(extract_extension_from_pattern(".gitignore"), None); + } + + #[test] + fn test_extract_extension_from_pattern_multi_dot_extension() { + assert_eq!( + extract_extension_from_pattern("foo.tar.gz"), + Some("gz".to_string()) + ); + } + + #[test] + fn test_build_effective_extension_map_overrides_with_file_patterns() { + let config = ServerConfig { + workspace: WorkspaceConfig::default(), + lsp_servers: vec![LspServerConfig { + language_id: "cpp".to_string(), + command: "clangd".to_string(), + args: vec![], + env: HashMap::new(), + file_patterns: vec!["**/*.c".to_string(), "**/*.h".to_string()], + initialization_options: None, + timeout_seconds: 30, + heuristics: None, + }], + }; + + let map = config.build_effective_extension_map(); + assert_eq!(map.get("c"), Some(&"cpp".to_string())); + assert_eq!(map.get("h"), Some(&"cpp".to_string())); + } + + #[test] + fn test_build_effective_extension_map_ignores_complex_patterns_without_extension() { + let config = ServerConfig { + workspace: WorkspaceConfig::default(), + lsp_servers: vec![LspServerConfig { + language_id: "cpp".to_string(), + command: "clangd".to_string(), + args: vec![], + env: HashMap::new(), + file_patterns: vec!["**/*".to_string(), "**/*.{h,hpp}".to_string()], + initialization_options: None, + timeout_seconds: 30, + heuristics: None, + }], + }; + + let map = config.build_effective_extension_map(); + // Default C/C++ mappings remain unchanged when patterns cannot be parsed. + assert_eq!(map.get("h"), Some(&"c".to_string())); + } + #[test] fn test_get_language_for_extension() { let workspace = WorkspaceConfig { diff --git a/crates/mcpls-core/src/lib.rs b/crates/mcpls-core/src/lib.rs index a0e4ec7..37d8076 100644 --- a/crates/mcpls-core/src/lib.rs +++ b/crates/mcpls-core/src/lib.rs @@ -39,9 +39,9 @@ use std::sync::Arc; use bridge::Translator; pub use config::ServerConfig; pub use error::Error; -use lsp::{LspServer, ServerInitConfig}; +use lsp::{LspNotification, LspServer, ServerInitConfig}; use rmcp::ServiceExt; -use tokio::sync::Mutex; +use tokio::sync::{Mutex, mpsc}; use tracing::{error, info, warn}; /// Resolve workspace roots from config or current directory. @@ -111,11 +111,21 @@ pub async fn serve(config: ServerConfig) -> Result<(), Error> { info!("Starting MCPLS server..."); let workspace_roots = resolve_workspace_roots(&config.workspace.roots); - let extension_map = config.workspace.build_extension_map(); + let extension_map = config.build_effective_extension_map(); let max_depth = Some(config.workspace.heuristics_max_depth); let mut translator = Translator::new().with_extensions(extension_map); translator.set_workspace_roots(workspace_roots.clone()); + let translator = Arc::new(Mutex::new(translator)); + + let (notification_tx, mut notification_rx) = mpsc::channel::(256); + let notification_translator = Arc::clone(&translator); + tokio::spawn(async move { + while let Some(notification) = notification_rx.recv().await { + let mut translator = notification_translator.lock().await; + translator.handle_notification(notification); + } + }); // Build configurations for batch spawning with heuristics filtering let applicable_configs: Vec = config @@ -148,7 +158,8 @@ pub async fn serve(config: ServerConfig) -> Result<(), Error> { ); // Spawn all servers with graceful degradation - let result = LspServer::spawn_batch(&applicable_configs).await; + let result = + LspServer::spawn_batch_with_notifications(&applicable_configs, Some(notification_tx)).await; // Handle the three possible outcomes if result.all_failed() { @@ -178,16 +189,17 @@ pub async fn serve(config: ServerConfig) -> Result<(), Error> { // Register all successfully initialized servers let server_count = result.server_count(); - for (language_id, server) in result.servers { - let client = server.client().clone(); - translator.register_client(language_id.clone(), client); - translator.register_server(language_id.clone(), server); + { + let mut translator = translator.lock().await; + for (language_id, server) in result.servers { + let client = server.client().clone(); + translator.register_client(language_id.clone(), client); + translator.register_server(language_id.clone(), server); + } } info!("Proceeding with {} LSP server(s)", server_count); - let translator = Arc::new(Mutex::new(translator)); - info!("Starting MCP server with rmcp..."); let mcp_server = mcp::McplsServer::new(translator); diff --git a/crates/mcpls-core/src/lsp/lifecycle.rs b/crates/mcpls-core/src/lsp/lifecycle.rs index fcef85b..00803db 100644 --- a/crates/mcpls-core/src/lsp/lifecycle.rs +++ b/crates/mcpls-core/src/lsp/lifecycle.rs @@ -16,6 +16,7 @@ use lsp_types::{ ClientCapabilities, ClientInfo, GeneralClientCapabilities, InitializeParams, InitializeResult, InitializedParams, PositionEncodingKind, ServerCapabilities, Uri, WorkspaceFolder, }; +use tokio::sync::mpsc; use tokio::process::Command; use tokio::time::Duration; use tracing::{debug, info}; @@ -24,6 +25,7 @@ use crate::config::LspServerConfig; use crate::error::{Error, Result, ServerSpawnFailure}; use crate::lsp::client::LspClient; use crate::lsp::transport::LspTransport; +use crate::lsp::types::LspNotification; /// State of an LSP server connection. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -199,6 +201,17 @@ impl LspServer { /// - Initialize request fails or times out /// - Server returns error during initialization pub async fn spawn(config: ServerInitConfig) -> Result { + Self::spawn_with_notifications(config, None).await + } + + /// Spawn and initialize LSP server with optional notification forwarding. + /// + /// When a notification sender is provided, server notifications are + /// forwarded to the caller for caching or further processing. + pub async fn spawn_with_notifications( + config: ServerInitConfig, + notification_tx: Option>, + ) -> Result { info!( "Spawning LSP server: {} {:?}", config.server_config.command, config.server_config.args @@ -226,7 +239,14 @@ impl LspServer { .ok_or_else(|| Error::Transport("Failed to capture stdout".to_string()))?; let transport = LspTransport::new(stdin, stdout); - let client = LspClient::from_transport(config.server_config.clone(), transport); + let client = match notification_tx { + Some(notification_tx) => LspClient::from_transport_with_notifications( + config.server_config.clone(), + transport, + notification_tx, + ), + None => LspClient::from_transport(config.server_config.clone(), transport), + }; let (capabilities, position_encoding) = Self::initialize(&client, &config).await?; @@ -367,6 +387,28 @@ impl LspServer { &self.client } + /// Create an `LspServer` for unit tests without full LSP initialization. + #[cfg(test)] + pub(crate) fn new_for_tests( + client: LspClient, + capabilities: ServerCapabilities, + position_encoding: PositionEncodingKind, + ) -> Self { + let child = Command::new("cat") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .expect("failed to spawn test child process"); + + Self { + client, + capabilities, + position_encoding, + _child: child, + } + } + /// Shutdown server gracefully. /// /// Sends shutdown request, waits for response, then sends exit notification. @@ -437,13 +479,21 @@ impl LspServer { /// # } /// ``` pub async fn spawn_batch(configs: &[ServerInitConfig]) -> ServerInitResult { + Self::spawn_batch_with_notifications(configs, None).await + } + + /// Spawn multiple LSP servers with optional notification forwarding. + pub async fn spawn_batch_with_notifications( + configs: &[ServerInitConfig], + notification_tx: Option>, + ) -> ServerInitResult { let mut result = ServerInitResult::new(); for config in configs { let language_id = config.server_config.language_id.clone(); let command = config.server_config.command.clone(); - match Self::spawn(config.clone()).await { + match Self::spawn_with_notifications(config.clone(), notification_tx.clone()).await { Ok(server) => { info!( "Successfully spawned LSP server: {} ({})", diff --git a/crates/mcpls-core/src/lsp/mod.rs b/crates/mcpls-core/src/lsp/mod.rs index ccc8585..84e2cf0 100644 --- a/crates/mcpls-core/src/lsp/mod.rs +++ b/crates/mcpls-core/src/lsp/mod.rs @@ -11,4 +11,7 @@ mod types; pub use client::LspClient; pub use lifecycle::{LspServer, ServerInitConfig, ServerInitResult, ServerState}; pub use transport::LspTransport; -pub use types::{InboundMessage, JsonRpcNotification, JsonRpcRequest, JsonRpcResponse, RequestId}; +pub use types::{ + InboundMessage, JsonRpcNotification, JsonRpcRequest, JsonRpcResponse, LspNotification, + RequestId, +}; diff --git a/crates/mcpls-core/src/mcp/server.rs b/crates/mcpls-core/src/mcp/server.rs index 861821d..6028c76 100644 --- a/crates/mcpls-core/src/mcp/server.rs +++ b/crates/mcpls-core/src/mcp/server.rs @@ -117,7 +117,7 @@ impl McplsServer { /// Get diagnostics for a file. #[tool( - description = "Diagnostics for a file. Returns errors, warnings, and hints with severity and location." + description = "Diagnostics for a file. Uses pull diagnostics when the server supports them, otherwise falls back to cached published diagnostics." )] async fn get_diagnostics( &self,