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:
parent
86cfae8c10
commit
5fee7fdf39
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 ¬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<LspHoverResult> {
|
||||
let contents = value.get("contents")?;
|
||||
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
Loading…
Reference in New Issue