From 4be1d43ada6da3e75f6971af0c2f98e7f47336c3 Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Sun, 26 Apr 2026 23:49:22 -0500 Subject: [PATCH] feat: auto-LSP integration with didOpen/didChange and diagnostic enrichment Wire LSP into the Read/Edit/Write tool flow so the agent automatically gets diagnostics after file operations: - lsp_transport: Add LspServerMessage enum, read_message() for handling both responses and server-initiated notifications, notification queue with drain_notifications(), send_request now handles interleaved publishDiagnostics without breaking - lsp_process: Add did_open(), did_change(), drain_diagnostics(), open file tracking (HashSet) and version counters for didChange, language_id_for_path() and severity_name() helpers - lsp_client: Add notify_file_open(), notify_file_change(), fetch_diagnostics_for_file() with best-effort graceful fallback, registry-level open file tracking, diagnostic caching - tools: Enrich run_read_file with didOpen + diagnostics, run_write_file and run_edit_file with didChange + diagnostics, format_diagnostic_appendix() for readable diagnostic output appended to tool results All enrichment is non-blocking: if no LSP server is available, tools work exactly as before. No errors propagate from the LSP layer. Co-Authored-By: Claude Opus 4.7 --- rust/crates/runtime/src/lsp_client.rs | 136 +++++++++++++++++++++- rust/crates/runtime/src/lsp_discovery.rs | 4 + rust/crates/runtime/src/lsp_process.rs | 137 ++++++++++++++++++++++- rust/crates/runtime/src/lsp_transport.rs | 99 +++++++++++++--- rust/crates/tools/src/lib.rs | 98 ++++++++++++++++ 5 files changed, 455 insertions(+), 19 deletions(-) diff --git a/rust/crates/runtime/src/lsp_client.rs b/rust/crates/runtime/src/lsp_client.rs index aa4e1337..e7e4e121 100644 --- a/rust/crates/runtime/src/lsp_client.rs +++ b/rust/crates/runtime/src/lsp_client.rs @@ -1,7 +1,7 @@ #![allow(clippy::should_implement_trait, clippy::must_use_candidate)] //! LSP (Language Server Protocol) client registry for tool dispatch. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::Path; use std::sync::{Arc, Mutex}; @@ -156,6 +156,7 @@ pub struct LspRegistry { #[derive(Debug, Default)] struct RegistryInner { servers: HashMap, + open_files: HashSet, } impl LspRegistry { @@ -420,6 +421,139 @@ impl LspRegistry { Ok(()) } + /// Notify the LSP server that a file was opened and collect any diagnostics. + /// Best-effort: returns empty vec if no server is available. + pub fn notify_file_open(&self, path: &str, content: &str) -> Vec { + let Some(language) = Self::language_for_path(path) else { + return Vec::new(); + }; + + // Check if already open + { + let inner = self.inner.lock().expect("lsp registry lock poisoned"); + if inner.open_files.contains(path) { + return Vec::new(); + } + } + + // Lazy-start the server + if self.start_server(&language).is_err() { + return Vec::new(); + } + + // Get the process handle and send didOpen + let process_arc = { + let inner = self.inner.lock().expect("lsp registry lock poisoned"); + match inner.servers.get(&language).and_then(|e| e.process.clone()) { + Some(p) => p, + None => return Vec::new(), + } + }; + + let mut diagnostics = Vec::new(); + if let Ok(mut process) = process_arc.lock() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build(); + if let Ok(rt) = rt { + let _ = rt.block_on(process.did_open(path, content)); + diagnostics = process.drain_diagnostics(); + } + } + + // Cache diagnostics in registry state + if !diagnostics.is_empty() { + let diag_path = path.to_owned(); + let diags = diagnostics.clone(); + let mut inner = self.inner.lock().expect("lsp registry lock poisoned"); + if let Some(entry) = inner.servers.get_mut(&language) { + // Replace diagnostics for this file (publishDiagnostics is full replacement) + entry.state.diagnostics.retain(|d| d.path != diag_path); + entry.state.diagnostics.extend(diags); + } + } + + // Mark file as open + { + let mut inner = self.inner.lock().expect("lsp registry lock poisoned"); + inner.open_files.insert(path.to_owned()); + } + + diagnostics + } + + /// Notify the LSP server that a file changed and collect any diagnostics. + /// Best-effort: returns empty vec if no server is available. + pub fn notify_file_change(&self, path: &str, content: &str) -> Vec { + let Some(language) = Self::language_for_path(path) else { + return Vec::new(); + }; + + // Get the process handle + let process_arc = { + let inner = self.inner.lock().expect("lsp registry lock poisoned"); + match inner.servers.get(&language).and_then(|e| e.process.clone()) { + Some(p) => p, + None => return Vec::new(), + } + }; + + let mut diagnostics = Vec::new(); + if let Ok(mut process) = process_arc.lock() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build(); + if let Ok(rt) = rt { + let _ = rt.block_on(process.did_change(path, content)); + diagnostics = process.drain_diagnostics(); + } + } + + // Replace cached diagnostics for this file + if !diagnostics.is_empty() { + let diag_path = path.to_owned(); + let diags = diagnostics.clone(); + let mut inner = self.inner.lock().expect("lsp registry lock poisoned"); + if let Some(entry) = inner.servers.get_mut(&language) { + entry.state.diagnostics.retain(|d| d.path != diag_path); + entry.state.diagnostics.extend(diags); + } + } + + diagnostics + } + + /// Fetch diagnostics for a file by draining pending server notifications + /// and returning cached diagnostics. + pub fn fetch_diagnostics_for_file(&self, path: &str) -> Vec { + let Some(language) = Self::language_for_path(path) else { + return Vec::new(); + }; + + // Drain pending notifications from the transport + let process_arc = { + let inner = self.inner.lock().expect("lsp registry lock poisoned"); + inner.servers.get(&language).and_then(|e| e.process.clone()) + }; + + if let Some(process_arc) = process_arc { + if let Ok(mut process) = process_arc.lock() { + let new_diags = process.drain_diagnostics(); + if !new_diags.is_empty() { + let diag_path = path.to_owned(); + let mut inner = + self.inner.lock().expect("lsp registry lock poisoned"); + if let Some(entry) = inner.servers.get_mut(&language) { + entry.state.diagnostics.retain(|d| d.path != diag_path); + entry.state.diagnostics.extend(new_diags); + } + } + } + } + + self.get_diagnostics(path) + } + /// Dispatch an LSP action and return a structured result. #[allow(clippy::too_many_lines)] pub fn dispatch( diff --git a/rust/crates/runtime/src/lsp_discovery.rs b/rust/crates/runtime/src/lsp_discovery.rs index a717db60..03218824 100644 --- a/rust/crates/runtime/src/lsp_discovery.rs +++ b/rust/crates/runtime/src/lsp_discovery.rs @@ -112,6 +112,7 @@ pub fn command_exists_on_path(command: &str) -> bool { Command::new(command) .arg("--version") .output() +<<<<<<< HEAD <<<<<<< HEAD .is_ok() } @@ -141,6 +142,9 @@ fn rustup_component_works(component: &str) -> bool { .map(|output| output.status.success()) .unwrap_or(false) >>>>>>> e9582034 (feat: full LSP (Language Server Protocol) integration) +======= + .is_ok() +>>>>>>> 73cc8277 (feat: auto-LSP integration with didOpen/didChange and diagnostic enrichment) } /// Discover LSP servers that are actually installed on the current system. diff --git a/rust/crates/runtime/src/lsp_process.rs b/rust/crates/runtime/src/lsp_process.rs index c57c832a..578d43fe 100644 --- a/rust/crates/runtime/src/lsp_process.rs +++ b/rust/crates/runtime/src/lsp_process.rs @@ -1,11 +1,12 @@ //! LSP process manager: spawns language servers and drives the LSP lifecycle. +use std::collections::{HashMap, HashSet}; use std::path::Path; use serde_json::Value as JsonValue; use crate::lsp_client::{ - LspCompletionItem, LspHoverResult, LspLocation, LspServerStatus, LspSymbol, + LspCompletionItem, LspDiagnostic, LspHoverResult, LspLocation, LspServerStatus, LspSymbol, }; use crate::lsp_transport::{LspTransport, LspTransportError}; @@ -16,6 +17,8 @@ pub struct LspProcess { root_uri: String, capabilities: Option, status: LspServerStatus, + open_files: HashSet, + version_counter: HashMap, } #[allow(clippy::cast_possible_truncation)] @@ -38,6 +41,8 @@ impl LspProcess { root_uri: root_uri.clone(), capabilities: None, status: LspServerStatus::Starting, + open_files: HashSet::new(), + version_counter: HashMap::new(), }; process.initialize(&canonical).await?; @@ -279,6 +284,103 @@ impl LspProcess { } } + /// Notify the server that a file was opened. Sends `textDocument/didOpen`. + /// No-op if the file is already tracked as open. + pub async fn did_open(&mut self, path: &str, content: &str) -> Result<(), LspProcessError> { + if self.open_files.contains(path) { + return Ok(()); + } + + let uri = path_to_uri(path); + let language_id = language_id_for_path(path); + let params = serde_json::json!({ + "textDocument": { + "uri": uri, + "languageId": language_id, + "version": 0, + "text": content + } + }); + + self.transport + .send_notification("textDocument/didOpen", Some(params)) + .await + .map_err(LspProcessError::Transport)?; + + self.open_files.insert(path.to_owned()); + self.version_counter.insert(path.to_owned(), 0); + Ok(()) + } + + /// Notify the server that a file's content changed. Sends `textDocument/didChange`. + pub async fn did_change(&mut self, path: &str, content: &str) -> Result<(), LspProcessError> { + let version = self.version_counter.get(path).map_or(1, |v| v + 1); + + let uri = path_to_uri(path); + let params = serde_json::json!({ + "textDocument": { "uri": uri, "version": version }, + "contentChanges": [{ "text": content }] + }); + + self.transport + .send_notification("textDocument/didChange", Some(params)) + .await + .map_err(LspProcessError::Transport)?; + + self.version_counter.insert(path.to_owned(), version); + Ok(()) + } + + /// Drain queued server notifications and extract `publishDiagnostics`. + #[allow(clippy::redundant_closure_for_method_calls)] + pub fn drain_diagnostics(&mut self) -> Vec { + let notifications = self.transport.drain_notifications(); + let mut diagnostics = Vec::new(); + for n in ¬ifications { + if n.method == "textDocument/publishDiagnostics" { + if let Some(params) = &n.params { + if let Some(uri) = params.get("uri").and_then(|v| v.as_str()) { + let path = uri_to_path(uri); + if let Some(diags) = params.get("diagnostics").and_then(|v| v.as_array()) + { + for d in diags { + diagnostics.push(LspDiagnostic { + path: path.clone(), + line: d + .get("range") + .and_then(|r| r.get("start")) + .and_then(|s| s.get("line")) + .and_then(|v| v.as_u64()) + .map_or(0, |v| v as u32), + character: d + .get("range") + .and_then(|r| r.get("start")) + .and_then(|s| s.get("character")) + .and_then(|v| v.as_u64()) + .map_or(0, |v| v as u32), + severity: d + .get("severity") + .and_then(|v| v.as_u64()) + .map_or_else(|| "error".to_owned(), severity_name), + message: d + .get("message") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_owned(), + source: d + .get("source") + .and_then(|v| v.as_str()) + .map(str::to_owned), + }); + } + } + } + } + } + } + diagnostics + } + #[must_use] pub fn status(&self) -> LspServerStatus { self.status @@ -360,6 +462,39 @@ fn uri_to_path(uri: &str) -> String { uri.strip_prefix("file://").unwrap_or(uri).to_owned() } +fn language_id_for_path(path: &str) -> String { + let ext = std::path::Path::new(path) + .extension() + .and_then(|e| e.to_str()) + .unwrap_or(""); + match ext { + "rs" => "rust", + "ts" => "typescript", + "tsx" => "typescriptreact", + "js" => "javascript", + "jsx" => "javascriptreact", + "py" => "python", + "go" => "go", + "java" => "java", + "c" | "h" => "c", + "cpp" | "hpp" | "cc" => "cpp", + "rb" => "ruby", + "lua" => "lua", + _ => ext, + } + .to_owned() +} + +fn severity_name(code: u64) -> String { + match code { + 1 => "error".to_owned(), + 2 => "warning".to_owned(), + 3 => "info".to_owned(), + 4 => "hint".to_owned(), + _ => format!("unknown({code})"), + } +} + fn parse_hover(value: &JsonValue) -> Option { let contents = value.get("contents")?; diff --git a/rust/crates/runtime/src/lsp_transport.rs b/rust/crates/runtime/src/lsp_transport.rs index 8ad1f832..015ac145 100644 --- a/rust/crates/runtime/src/lsp_transport.rs +++ b/rust/crates/runtime/src/lsp_transport.rs @@ -89,6 +89,14 @@ impl LspResponse { } } +/// A message received from an LSP server — either a response to a request +/// or a server-initiated notification (e.g. `textDocument/publishDiagnostics`). +#[derive(Debug, Clone)] +pub enum LspServerMessage { + Response(LspResponse), + Notification(LspNotification), +} + #[derive(Debug)] pub enum LspTransportError { Io(io::Error), @@ -138,6 +146,7 @@ pub struct LspTransport { stdout: BufReader, next_id: u64, request_timeout: Duration, + pending_notifications: Vec, } impl LspTransport { @@ -172,6 +181,7 @@ impl LspTransport { stdout: BufReader::new(stdout), next_id: 1, request_timeout, + pending_notifications: Vec::new(), }) } @@ -193,6 +203,7 @@ impl LspTransport { stdout: BufReader::new(stdout), next_id: 1, request_timeout, + pending_notifications: Vec::new(), } } @@ -235,12 +246,27 @@ impl LspTransport { let method_owned = method.to_string(); let timeout_duration = self.request_timeout; - let response = timeout(timeout_duration, self.read_response()) - .await - .map_err(|_| LspTransportError::Timeout { - method: method_owned, - timeout: timeout_duration, - })??; + let response = match timeout(timeout_duration, async { + loop { + match self.read_message().await { + Ok(LspServerMessage::Response(r)) => break Ok(r), + Ok(LspServerMessage::Notification(n)) => { + self.pending_notifications.push(n); + } + Err(e) => break Err(e), + } + } + }) + .await + { + Ok(inner) => inner, + Err(_) => { + return Err(LspTransportError::Timeout { + method: method_owned, + timeout: timeout_duration, + }) + } + }?; if response.jsonrpc != "2.0" { return Err(LspTransportError::InvalidResponse { @@ -266,8 +292,57 @@ impl LspTransport { Ok(response) } + /// Read a single message from the server, returning either a response or + /// a server-initiated notification (e.g. `publishDiagnostics`). + pub async fn read_message(&mut self) -> Result { + let payload = self.read_frame().await?; + let value: JsonValue = serde_json::from_slice(&payload).map_err(|error| { + LspTransportError::InvalidResponse { + method: "unknown".to_string(), + details: error.to_string(), + } + })?; + + // Responses have an "id" field; notifications have "method" but no "id" + if value.get("id").is_some() { + let response: LspResponse = serde_json::from_value(value).map_err(|error| { + LspTransportError::InvalidResponse { + method: "unknown".to_string(), + details: format!("failed to parse response: {error}"), + } + })?; + Ok(LspServerMessage::Response(response)) + } else if value.get("method").is_some() { + let notification: LspNotification = serde_json::from_value(value).map_err(|error| { + LspTransportError::InvalidResponse { + method: "unknown".to_string(), + details: format!("failed to parse notification: {error}"), + } + })?; + Ok(LspServerMessage::Notification(notification)) + } else { + Err(LspTransportError::InvalidResponse { + method: "unknown".to_string(), + details: "message has neither 'id' nor 'method'".to_string(), + }) + } + } + + /// Read a response from the server. Interleaved notifications are queued. pub async fn read_response(&mut self) -> Result { - self.read_jsonrpc_message().await + loop { + match self.read_message().await? { + LspServerMessage::Response(r) => return Ok(r), + LspServerMessage::Notification(n) => { + self.pending_notifications.push(n); + } + } + } + } + + /// Drain and return all queued server-initiated notifications. + pub fn drain_notifications(&mut self) -> Vec { + std::mem::take(&mut self.pending_notifications) } pub async fn shutdown(&mut self) -> Result<(), LspTransportError> { @@ -344,16 +419,6 @@ impl LspTransport { Ok(payload) } - - async fn read_jsonrpc_message( - &mut self, - ) -> Result { - let payload = self.read_frame().await?; - serde_json::from_slice(&payload).map_err(|error| LspTransportError::InvalidResponse { - method: "unknown".to_string(), - details: error.to_string(), - }) - } } #[cfg(test)] diff --git a/rust/crates/tools/src/lib.rs b/rust/crates/tools/src/lib.rs index 68d173a2..029c6240 100644 --- a/rust/crates/tools/src/lib.rs +++ b/rust/crates/tools/src/lib.rs @@ -15,9 +15,15 @@ use api::{ use plugins::PluginTool; use reqwest::blocking::Client; use runtime::{ +<<<<<<< HEAD check_freshness, dedupe_superseded_commit_events, edit_file_in_workspace, execute_bash, glob_search_in_workspace, grep_search_in_workspace, load_system_prompt, lsp_client::LspRegistry, +======= + check_freshness, dedupe_superseded_commit_events, edit_file, execute_bash, glob_search, + grep_search, load_system_prompt, + lsp_client::{LspDiagnostic, LspRegistry}, +>>>>>>> 73cc8277 (feat: auto-LSP integration with didOpen/didChange and diagnostic enrichment) mcp_tool_bridge::McpToolRegistry, permission_enforcer::{EnforcementResult, PermissionEnforcer}, read_file_in_workspace, @@ -2448,23 +2454,48 @@ fn branch_divergence_output( #[allow(clippy::needless_pass_by_value)] fn run_read_file(input: ReadFileInput) -> Result { +<<<<<<< HEAD let workspace = std::env::current_dir().map_err(|error| error.to_string())?; to_pretty_json( read_file_in_workspace(&input.path, input.offset, input.limit, &workspace) .map_err(io_to_string)?, ) +======= + let result = read_file(&input.path, input.offset, input.limit).map_err(io_to_string)?; + let mut output = to_pretty_json(result)?; + + // LSP enrichment: notify the server that the file was opened and append diagnostics + if let Some(diags) = lsp_enrichment_for_path(&input.path, &LspEvent::Open) { + output.push_str(&format_diagnostic_appendix(&diags)); + } + + Ok(output) +>>>>>>> 73cc8277 (feat: auto-LSP integration with didOpen/didChange and diagnostic enrichment) } #[allow(clippy::needless_pass_by_value)] fn run_write_file(input: WriteFileInput) -> Result { +<<<<<<< HEAD let workspace = std::env::current_dir().map_err(|error| error.to_string())?; to_pretty_json( write_file_in_workspace(&input.path, &input.content, &workspace).map_err(io_to_string)?, ) +======= + let result = write_file(&input.path, &input.content).map_err(io_to_string)?; + let mut output = to_pretty_json(result)?; + + // LSP enrichment: notify the server that the file changed and append diagnostics + if let Some(diags) = lsp_enrichment_for_path(&input.path, &LspEvent::Change) { + output.push_str(&format_diagnostic_appendix(&diags)); + } + + Ok(output) +>>>>>>> 73cc8277 (feat: auto-LSP integration with didOpen/didChange and diagnostic enrichment) } #[allow(clippy::needless_pass_by_value)] fn run_edit_file(input: EditFileInput) -> Result { +<<<<<<< HEAD let workspace = std::env::current_dir().map_err(|error| error.to_string())?; to_pretty_json( edit_file_in_workspace( @@ -2475,7 +2506,74 @@ fn run_edit_file(input: EditFileInput) -> Result { &workspace, ) .map_err(io_to_string)?, +======= + let result = edit_file( + &input.path, + &input.old_string, + &input.new_string, + input.replace_all.unwrap_or(false), +>>>>>>> 73cc8277 (feat: auto-LSP integration with didOpen/didChange and diagnostic enrichment) ) + .map_err(io_to_string)?; + + let mut output = to_pretty_json(result)?; + + // LSP enrichment: notify the server that the file changed and append diagnostics + let full_content = std::fs::read_to_string(&input.path).unwrap_or_default(); + if let Some(diags) = + lsp_enrichment_for_path_with_content(&input.path, &full_content, &LspEvent::Change) + { + output.push_str(&format_diagnostic_appendix(&diags)); + } + + Ok(output) +} + +enum LspEvent { + Open, + Change, +} + +fn lsp_enrichment_for_path(path: &str, event: &LspEvent) -> Option> { + let content = std::fs::read_to_string(path).ok()?; + lsp_enrichment_for_path_with_content(path, &content, event) +} + +fn lsp_enrichment_for_path_with_content( + path: &str, + content: &str, + event: &LspEvent, +) -> Option> { + let registry = global_lsp_registry(); + + registry.find_server_for_path(path)?; + + let diags = match event { + LspEvent::Open => registry.notify_file_open(path, content), + LspEvent::Change => registry.notify_file_change(path, content), + }; + + if diags.is_empty() { + None + } else { + Some(diags) + } +} + +fn format_diagnostic_appendix(diagnostics: &[LspDiagnostic]) -> String { + let mut lines = vec![String::from("\n--- LSP Diagnostics ---")]; + for d in diagnostics { + let source = d.source.as_deref().unwrap_or("lsp"); + lines.push(format!( + "[{}:{}] {} ({}): {}", + d.line + 1, + d.character + 1, + d.severity, + source, + d.message + )); + } + lines.join("\n") } #[allow(clippy::needless_pass_by_value)]