mod diagnostics;
mod hover;
#[cfg(test)]
mod test_helpers;
use std::sync::Arc;
use tokio::sync::mpsc;
use zeph_mcp::McpManager;
pub use crate::config::LspConfig;
pub struct LspNote {
pub kind: &'static str,
pub content: String,
pub estimated_tokens: usize,
}
type DiagnosticsRx = mpsc::Receiver<Option<LspNote>>;
pub struct LspHookRunner {
pub(crate) manager: Arc<McpManager>,
pub(crate) config: LspConfig,
pending_notes: Vec<LspNote>,
diagnostics_rxs: Vec<DiagnosticsRx>,
pub(crate) stats: LspStats,
}
#[derive(Debug, Default, Clone)]
pub struct LspStats {
pub diagnostics_injected: u64,
pub hover_injected: u64,
pub notes_dropped_budget: u64,
}
impl LspHookRunner {
#[must_use]
pub fn new(manager: Arc<McpManager>, config: LspConfig) -> Self {
Self {
manager,
config,
pending_notes: Vec::new(),
diagnostics_rxs: Vec::new(),
stats: LspStats::default(),
}
}
#[must_use]
pub fn stats(&self) -> &LspStats {
&self.stats
}
pub async fn is_available(&self) -> bool {
self.manager
.list_servers()
.await
.contains(&self.config.mcp_server_id)
}
pub async fn after_tool(
&mut self,
tool_name: &str,
tool_params: &serde_json::Value,
tool_output: &str,
token_counter: &Arc<zeph_memory::TokenCounter>,
sanitizer: &zeph_sanitizer::ContentSanitizer,
) {
if !self.config.enabled {
tracing::debug!(tool = tool_name, "LSP hook: skipped (disabled)");
return;
}
if !self.is_available().await {
tracing::debug!(tool = tool_name, "LSP hook: skipped (server unavailable)");
return;
}
match tool_name {
"write" if self.config.diagnostics.enabled => {
self.spawn_diagnostics_fetch(tool_params, token_counter, sanitizer);
}
"read" if self.config.hover.enabled => {
if let Some(note) =
hover::fetch_hover(self, tool_params, tool_output, token_counter, sanitizer)
.await
{
self.stats.hover_injected += 1;
self.pending_notes.push(note);
}
}
"write" => {
tracing::debug!(tool = tool_name, "LSP hook: skipped (diagnostics disabled)");
}
"read" => {
tracing::debug!(tool = tool_name, "LSP hook: skipped (hover disabled)");
}
_ => {}
}
}
fn spawn_diagnostics_fetch(
&mut self,
tool_params: &serde_json::Value,
token_counter: &Arc<zeph_memory::TokenCounter>,
sanitizer: &zeph_sanitizer::ContentSanitizer,
) {
let Some(path) = tool_params
.get("path")
.and_then(|v| v.as_str())
.map(ToOwned::to_owned)
else {
tracing::debug!("LSP hook: skipped diagnostics fetch (missing path)");
return;
};
tracing::debug!(tool = "write", path = %path, "LSP hook: spawning diagnostics fetch");
let manager = Arc::clone(&self.manager);
let config = self.config.clone();
let tc = Arc::clone(token_counter);
let sanitizer = sanitizer.clone();
let (tx, rx) = mpsc::channel(1);
self.diagnostics_rxs.push(rx);
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
let note =
diagnostics::fetch_diagnostics(manager.as_ref(), &config, &path, &tc, &sanitizer)
.await;
let _ = tx.send(note).await;
});
}
fn collect_background_diagnostics(&mut self) {
let mut still_pending = Vec::new();
for mut rx in self.diagnostics_rxs.drain(..) {
match rx.try_recv() {
Ok(Some(note)) => {
self.stats.diagnostics_injected += 1;
self.pending_notes.push(note);
}
Ok(None) | Err(mpsc::error::TryRecvError::Disconnected) => {
}
Err(mpsc::error::TryRecvError::Empty) => {
still_pending.push(rx);
}
}
}
self.diagnostics_rxs = still_pending;
}
#[must_use]
pub fn drain_notes(
&mut self,
token_counter: &Arc<zeph_memory::TokenCounter>,
) -> Option<String> {
use std::fmt::Write as _;
self.collect_background_diagnostics();
if self.pending_notes.is_empty() {
return None;
}
let mut output = String::new();
let mut remaining = self.config.token_budget;
for note in self.pending_notes.drain(..) {
if note.estimated_tokens > remaining {
tracing::debug!(
kind = note.kind,
tokens = note.estimated_tokens,
remaining,
"LSP note dropped: token budget exceeded"
);
self.stats.notes_dropped_budget += 1;
continue;
}
remaining -= note.estimated_tokens;
if !output.is_empty() {
output.push('\n');
}
let _ = write!(output, "[lsp {}]\n{}", note.kind, note.content);
}
if output.is_empty() {
None
} else {
let _ = token_counter; Some(output)
}
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use zeph_mcp::McpManager;
use zeph_memory::TokenCounter;
use super::*;
use crate::config::{DiagnosticSeverity, LspConfig};
fn make_runner(enabled: bool) -> LspHookRunner {
let enforcer = zeph_mcp::PolicyEnforcer::new(vec![]);
let manager = Arc::new(McpManager::new(vec![], vec![], enforcer));
LspHookRunner::new(
manager,
LspConfig {
enabled,
token_budget: 500,
..LspConfig::default()
},
)
}
#[test]
fn drain_notes_empty() {
let mut runner = make_runner(true);
let tc = Arc::new(TokenCounter::default());
assert!(runner.drain_notes(&tc).is_none());
}
#[test]
fn drain_notes_formats_correctly() {
let tc = Arc::new(TokenCounter::default());
let mut runner = make_runner(true);
let tokens = tc.count_tokens("hello world");
runner.pending_notes.push(LspNote {
kind: "diagnostics",
content: "hello world".into(),
estimated_tokens: tokens,
});
let result = runner.drain_notes(&tc).unwrap();
assert!(result.starts_with("[lsp diagnostics]\nhello world"));
}
#[test]
fn drain_notes_budget_enforcement() {
let tc = Arc::new(TokenCounter::default());
let enforcer = zeph_mcp::PolicyEnforcer::new(vec![]);
let manager = Arc::new(McpManager::new(vec![], vec![], enforcer));
let mut runner = LspHookRunner::new(
manager,
LspConfig {
enabled: true,
token_budget: 1, ..LspConfig::default()
},
);
runner.pending_notes.push(LspNote {
kind: "diagnostics",
content: "a very long diagnostic message that exceeds one token".into(),
estimated_tokens: 20,
});
let result = runner.drain_notes(&tc);
assert!(result.is_none());
assert_eq!(runner.stats.notes_dropped_budget, 1);
}
#[test]
fn lsp_config_defaults() {
let cfg = LspConfig::default();
assert!(!cfg.enabled);
assert_eq!(cfg.mcp_server_id, "mcpls");
assert_eq!(cfg.token_budget, 2000);
assert_eq!(cfg.call_timeout_secs, 5);
assert!(cfg.diagnostics.enabled);
assert!(!cfg.hover.enabled);
assert_eq!(cfg.diagnostics.min_severity, DiagnosticSeverity::Error);
}
#[test]
fn lsp_config_toml_parse() {
let toml_str = r#"
enabled = true
mcp_server_id = "my-lsp"
token_budget = 3000
[diagnostics]
enabled = true
max_per_file = 10
min_severity = "warning"
[hover]
enabled = true
max_symbols = 5
"#;
let cfg: LspConfig = toml::from_str(toml_str).expect("parse LspConfig");
assert!(cfg.enabled);
assert_eq!(cfg.mcp_server_id, "my-lsp");
assert_eq!(cfg.token_budget, 3000);
assert_eq!(cfg.diagnostics.max_per_file, 10);
assert_eq!(cfg.diagnostics.min_severity, DiagnosticSeverity::Warning);
assert!(cfg.hover.enabled);
assert_eq!(cfg.hover.max_symbols, 5);
}
#[tokio::test]
async fn after_tool_disabled_does_not_queue_notes() {
use zeph_sanitizer::{ContentIsolationConfig, ContentSanitizer};
let tc = Arc::new(TokenCounter::default());
let sanitizer = ContentSanitizer::new(&ContentIsolationConfig::default());
let mut runner = make_runner(false);
let params = serde_json::json!({ "path": "src/main.rs" });
runner
.after_tool("write", ¶ms, "", &tc, &sanitizer)
.await;
assert!(runner.diagnostics_rxs.is_empty());
assert!(runner.pending_notes.is_empty());
}
#[tokio::test]
async fn after_tool_unavailable_skips_on_write() {
use zeph_sanitizer::{ContentIsolationConfig, ContentSanitizer};
let tc = Arc::new(TokenCounter::default());
let sanitizer = ContentSanitizer::new(&ContentIsolationConfig::default());
let mut runner = make_runner(true);
let params = serde_json::json!({ "path": "src/main.rs" });
runner
.after_tool("write", ¶ms, "", &tc, &sanitizer)
.await;
assert!(runner.diagnostics_rxs.is_empty());
}
#[test]
fn collect_background_diagnostics_multiple_writes() {
use tokio::sync::mpsc;
let mut runner = make_runner(true);
let tc = Arc::new(TokenCounter::default());
for i in 0..2u64 {
let (tx, rx) = mpsc::channel(1);
runner.diagnostics_rxs.push(rx);
let note = LspNote {
kind: "diagnostics",
content: format!("error {i}"),
estimated_tokens: 5,
};
tx.try_send(Some(note)).unwrap();
}
runner.collect_background_diagnostics();
assert_eq!(runner.pending_notes.len(), 2);
assert_eq!(runner.stats.diagnostics_injected, 2);
assert!(runner.diagnostics_rxs.is_empty());
let result = runner.drain_notes(&tc).unwrap();
assert!(result.contains("error 0"));
assert!(result.contains("error 1"));
}
}