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 <noreply@anthropic.com>
This commit is contained in:
TheArchitectit 2026-04-26 23:49:22 -05:00
parent 86cfae8c10
commit 5fee7fdf39
4 changed files with 354 additions and 20 deletions

View File

@ -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<String, LspServerEntry>,
open_files: HashSet<String>,
}
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<LspDiagnostic> {
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<LspDiagnostic> {
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<LspDiagnostic> {
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(

View File

@ -109,7 +109,7 @@ pub fn command_exists_on_path(command: &str) -> bool {
Command::new(command)
.arg("--version")
.output()
.map_or(false, |_| true)
.is_ok()
}
/// Discover LSP servers that are actually installed on the current system.

View File

@ -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<JsonValue>,
status: LspServerStatus,
open_files: HashSet<String>,
version_counter: HashMap<String, u32>,
}
#[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<LspDiagnostic> {
let notifications = self.transport.drain_notifications();
let mut diagnostics = Vec::new();
for n in &notifications {
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<LspHoverResult> {
let contents = value.get("contents")?;

View File

@ -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<ChildStdout>,
next_id: u64,
request_timeout: Duration,
pending_notifications: Vec<LspNotification>,
}
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<LspServerMessage, LspTransportError> {
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<LspResponse, LspTransportError> {
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<LspNotification> {
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<T: serde::de::DeserializeOwned>(
&mut self,
) -> Result<T, LspTransportError> {
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)]