use std::path::Path;
use std::time::Duration;
use async_trait::async_trait;
use serde::Serialize;
use serde_json::{Value, json};
#[derive(Debug, Clone, Copy, Default)]
pub struct LspEditContext<'a> {
pub pre_content: Option<&'a str>,
pub post_content: Option<&'a str>,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct ToolDiagnostic {
pub severity: String,
pub line: u32,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub code: Option<String>,
}
#[async_trait]
pub trait LspGate: Send + Sync {
async fn snapshot_baseline(
&self,
ctx: &crate::registry::ToolContext,
path: &Path,
timeout: Duration,
);
async fn pull_diagnostics(
&self,
ctx: &crate::registry::ToolContext,
path: &Path,
timeout: Duration,
edit: Option<LspEditContext<'_>>,
) -> Vec<ToolDiagnostic>;
}
const MAX_PER_FILE: usize = 20;
const MAX_TOTAL_CHARS: usize = 4_000;
pub fn format_lsp_diagnostics_block(path: &Path, items: &[ToolDiagnostic]) -> Option<String> {
if items.is_empty() {
return None;
}
let limited = items.iter().take(MAX_PER_FILE);
let extra = items.len().saturating_sub(MAX_PER_FILE);
let mut lines: Vec<String> = limited
.map(|item| {
let code = item
.code
.as_ref()
.map(|c| format!(" [{c}]"))
.unwrap_or_default();
format!(
"{} [{}:{}] {}{}",
item.severity.to_uppercase(),
item.line,
1,
item.message,
code
)
})
.collect();
if extra > 0 {
lines.push(format!("... and {extra} more"));
}
let body = lines.join("\n");
let block = format!(
"<diagnostics file=\"{}\">\n{body}\n</diagnostics>",
path.display()
);
let prefix = "LSP diagnostics introduced by this edit:\n";
Some(truncate_lsp_block(&format!("{prefix}{block}")))
}
fn truncate_lsp_block(s: &str) -> String {
if s.len() <= MAX_TOTAL_CHARS {
return s.to_string();
}
const MARKER: &str = "\n…[truncated]";
let keep = MAX_TOTAL_CHARS.saturating_sub(MARKER.len());
format!("{}{MARKER}", &s[..keep])
}
pub struct LspWriteHook {
pre_content: Option<String>,
}
impl LspWriteHook {
pub fn with_pre_content(pre_content: Option<String>) -> Self {
Self { pre_content }
}
pub async fn capture_before(ctx: &crate::registry::ToolContext, path: &Path) -> Self {
let pre_content = tokio::fs::read_to_string(path).await.ok();
if ctx.config.lsp_enabled
&& let Some(gate) = ctx.lsp_gate.as_ref()
{
let timeout = Duration::from_millis(ctx.config.lsp_post_write_timeout_ms.max(1));
gate.snapshot_baseline(ctx, path, timeout).await;
}
Self { pre_content }
}
pub async fn attach_after(
self,
ctx: &crate::registry::ToolContext,
path: &Path,
result: &mut Value,
post_content: &str,
) {
attach_post_write_diagnostics(
ctx,
path,
result,
self.pre_content.as_deref(),
Some(post_content),
)
.await;
}
}
pub async fn attach_post_write_diagnostics(
ctx: &crate::registry::ToolContext,
path: &Path,
result: &mut Value,
pre_content: Option<&str>,
post_content: Option<&str>,
) {
if !ctx.config.lsp_enabled {
return;
}
let Some(gate) = ctx.lsp_gate.as_ref() else {
return;
};
let timeout_ms = ctx.config.lsp_post_write_timeout_ms.max(1);
let timeout = Duration::from_millis(timeout_ms);
let edit = Some(LspEditContext {
pre_content,
post_content,
});
let diagnostics = match tokio::time::timeout(
timeout,
gate.pull_diagnostics(ctx, path, timeout, edit),
)
.await
{
Ok(items) => items,
Err(_) => {
tracing::debug!(
path = %path.display(),
timeout_ms,
"post-write LSP diagnostics timed out"
);
Vec::new()
}
};
if let Some(obj) = result.as_object_mut() {
obj.insert("diagnostics".into(), json!(diagnostics));
if let Some(block) = format_lsp_diagnostics_block(path, &diagnostics) {
obj.insert("lsp_diagnostics".into(), json!(block));
}
}
}
#[cfg(test)]
pub struct MockLspGate {
pub diagnostics: Vec<ToolDiagnostic>,
}
#[cfg(test)]
#[async_trait]
impl LspGate for MockLspGate {
async fn snapshot_baseline(
&self,
_ctx: &crate::registry::ToolContext,
_path: &Path,
_timeout: Duration,
) {
}
async fn pull_diagnostics(
&self,
_ctx: &crate::registry::ToolContext,
_path: &Path,
_timeout: Duration,
_edit: Option<LspEditContext<'_>>,
) -> Vec<ToolDiagnostic> {
self.diagnostics.clone()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::registry::ToolContext;
use std::sync::Arc;
use tempfile::TempDir;
#[tokio::test]
async fn attach_injects_diagnostics_array() {
let dir = TempDir::new().expect("tmpdir");
let mut ctx = ToolContext::test_context();
ctx.cwd = dir.path().to_path_buf();
ctx.config.lsp_enabled = true;
ctx.lsp_gate = Some(Arc::new(MockLspGate {
diagnostics: vec![ToolDiagnostic {
severity: "error".into(),
line: 1,
message: "cannot find type `Bar`".into(),
code: Some("E0412".into()),
}],
}));
let mut value = json!({"ok": true, "path": "src/foo.rs"});
attach_post_write_diagnostics(
&ctx,
dir.path().join("src/foo.rs").as_path(),
&mut value,
None,
None,
)
.await;
let diags = value["diagnostics"].as_array().expect("diagnostics array");
assert_eq!(diags.len(), 1);
assert_eq!(diags[0]["severity"], "error");
assert!(
value["lsp_diagnostics"]
.as_str()
.unwrap()
.contains("<diagnostics")
);
}
#[tokio::test]
async fn attach_noop_when_lsp_disabled() {
let dir = TempDir::new().expect("tmpdir");
let mut ctx = ToolContext::test_context();
ctx.config.lsp_enabled = false;
ctx.lsp_gate = Some(Arc::new(MockLspGate {
diagnostics: vec![ToolDiagnostic {
severity: "error".into(),
line: 1,
message: "x".into(),
code: None,
}],
}));
let mut value = json!({"ok": true});
attach_post_write_diagnostics(
&ctx,
dir.path().join("x.rs").as_path(),
&mut value,
None,
None,
)
.await;
assert!(value.get("diagnostics").is_none());
}
#[tokio::test]
async fn write_hook_skips_attach_when_lsp_disabled() {
let dir = TempDir::new().expect("tmpdir");
let path = dir.path().join("main.rs");
tokio::fs::write(&path, "fn main() {}\n")
.await
.expect("write");
let mut ctx = ToolContext::test_context();
ctx.cwd = dir.path().to_path_buf();
ctx.config.lsp_enabled = false;
ctx.lsp_gate = Some(Arc::new(MockLspGate {
diagnostics: vec![ToolDiagnostic {
severity: "error".into(),
line: 1,
message: "should not appear".into(),
code: None,
}],
}));
let hook = LspWriteHook::capture_before(&ctx, &path).await;
let mut value = json!({"ok": true});
hook.attach_after(&ctx, &path, &mut value, "fn main() {}\n")
.await;
assert!(value.get("diagnostics").is_none());
}
#[test]
fn truncate_caps_long_blocks() {
let long = "x".repeat(5000);
let out = truncate_lsp_block(&long);
assert!(out.len() <= MAX_TOTAL_CHARS);
assert!(out.ends_with("…[truncated]"));
}
}