1mod diagnostics;
19mod hover;
20#[cfg(test)]
21mod test_helpers;
22
23use std::sync::Arc;
24
25use tokio::sync::mpsc;
26use zeph_mcp::McpManager;
27
28use crate::agent::agent_supervisor::{BackgroundSupervisor, TaskClass};
29
30pub use crate::config::LspConfig;
31
32pub struct LspNote {
34 pub kind: &'static str,
36 pub content: String,
38 pub estimated_tokens: usize,
40}
41
42type DiagnosticsRx = mpsc::Receiver<Option<LspNote>>;
44
45pub struct LspHookRunner {
47 pub(crate) manager: Arc<McpManager>,
48 pub(crate) config: LspConfig,
49 pending_notes: Vec<LspNote>,
51 diagnostics_rxs: Vec<DiagnosticsRx>,
55 pub(crate) stats: LspStats,
57}
58
59#[derive(Debug, Default, Clone)]
61pub struct LspStats {
62 pub diagnostics_injected: u64,
63 pub hover_injected: u64,
64 pub notes_dropped_budget: u64,
65}
66
67impl LspHookRunner {
68 #[must_use]
70 pub fn new(manager: Arc<McpManager>, config: LspConfig) -> Self {
71 Self {
72 manager,
73 config,
74 pending_notes: Vec::new(),
75 diagnostics_rxs: Vec::new(),
76 stats: LspStats::default(),
77 }
78 }
79
80 #[must_use]
82 pub fn stats(&self) -> &LspStats {
83 &self.stats
84 }
85
86 pub async fn is_available(&self) -> bool {
92 tracing::debug!("lsp_hooks: checking is_available");
93 let result = if let Ok(servers) = tokio::time::timeout(
94 std::time::Duration::from_secs(2),
95 self.manager.list_servers(),
96 )
97 .await
98 {
99 servers.contains(&self.config.mcp_server_id)
100 } else {
101 tracing::warn!("lsp_hooks: is_available check timed out after 2s");
102 false
103 };
104 tracing::debug!(available = result, "lsp_hooks: is_available check complete");
105 result
106 }
107
108 pub(crate) async fn after_tool(
116 &mut self,
117 tool_name: &str,
118 tool_params: &serde_json::Value,
119 tool_output: &str,
120 token_counter: &Arc<zeph_memory::TokenCounter>,
121 sanitizer: &zeph_sanitizer::ContentSanitizer,
122 supervisor: &mut BackgroundSupervisor,
123 ) {
124 if !self.config.enabled {
125 tracing::debug!(tool = tool_name, "LSP hook: skipped (disabled)");
126 return;
127 }
128 tracing::debug!(tool = tool_name, "LSP after_tool: checking availability");
129 let avail = self.is_available().await;
130 tracing::debug!(
131 tool = tool_name,
132 available = avail,
133 "LSP after_tool: availability checked"
134 );
135 if !avail {
136 tracing::debug!(tool = tool_name, "LSP hook: skipped (server unavailable)");
137 return;
138 }
139
140 match tool_name {
141 "write" if self.config.diagnostics.enabled => {
142 self.spawn_diagnostics_fetch(tool_params, token_counter, sanitizer, supervisor);
143 }
144 "read" if self.config.hover.enabled => {
145 if let Some(note) =
146 hover::fetch_hover(self, tool_params, tool_output, token_counter, sanitizer)
147 .await
148 {
149 self.stats.hover_injected += 1;
150 self.pending_notes.push(note);
151 }
152 }
153 "write" => {
154 tracing::debug!(tool = tool_name, "LSP hook: skipped (diagnostics disabled)");
155 }
156 "read" => {
157 tracing::debug!(tool = tool_name, "LSP hook: skipped (hover disabled)");
158 }
159 _ => {}
160 }
161 }
162
163 fn spawn_diagnostics_fetch(
173 &mut self,
174 tool_params: &serde_json::Value,
175 token_counter: &Arc<zeph_memory::TokenCounter>,
176 sanitizer: &zeph_sanitizer::ContentSanitizer,
177 supervisor: &mut BackgroundSupervisor,
178 ) {
179 let Some(path) = tool_params
180 .get("path")
181 .and_then(|v| v.as_str())
182 .map(ToOwned::to_owned)
183 else {
184 tracing::debug!("LSP hook: skipped diagnostics fetch (missing path)");
185 return;
186 };
187
188 tracing::debug!(tool = "write", path = %path, "LSP hook: spawning diagnostics fetch");
189
190 let manager = Arc::clone(&self.manager);
191 let config = self.config.clone();
192 let tc = Arc::clone(token_counter);
193 let sanitizer = sanitizer.clone();
194
195 let (tx, rx) = mpsc::channel(1);
196 self.diagnostics_rxs.push(rx);
197
198 supervisor.spawn(TaskClass::Enrichment, "lsp_diagnostics_fetch", async move {
199 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
203
204 let note =
205 diagnostics::fetch_diagnostics(manager.as_ref(), &config, &path, &tc, &sanitizer)
206 .await;
207 let _ = tx.send(note).await;
210 });
211 }
212
213 fn collect_background_diagnostics(&mut self) {
218 let mut still_pending = Vec::new();
219 for mut rx in self.diagnostics_rxs.drain(..) {
220 match rx.try_recv() {
221 Ok(Some(note)) => {
222 self.stats.diagnostics_injected += 1;
223 self.pending_notes.push(note);
224 }
225 Ok(None) | Err(mpsc::error::TryRecvError::Disconnected) => {
226 }
228 Err(mpsc::error::TryRecvError::Empty) => {
229 still_pending.push(rx);
231 }
232 }
233 }
234 self.diagnostics_rxs = still_pending;
235 }
236
237 #[must_use]
242 pub fn drain_notes(
243 &mut self,
244 token_counter: &Arc<zeph_memory::TokenCounter>,
245 ) -> Option<String> {
246 use std::fmt::Write as _;
247 self.collect_background_diagnostics();
248
249 if self.pending_notes.is_empty() {
250 return None;
251 }
252
253 let mut output = String::new();
254 let mut remaining = self.config.token_budget;
255
256 for note in self.pending_notes.drain(..) {
257 if note.estimated_tokens > remaining {
258 tracing::debug!(
259 kind = note.kind,
260 tokens = note.estimated_tokens,
261 remaining,
262 "LSP note dropped: token budget exceeded"
263 );
264 self.stats.notes_dropped_budget += 1;
265 continue;
266 }
267 remaining -= note.estimated_tokens;
268 if !output.is_empty() {
269 output.push('\n');
270 }
271 let _ = write!(output, "[lsp {}]\n{}", note.kind, note.content);
272 }
273
274 if output.is_empty() {
276 None
277 } else {
278 let _ = token_counter; Some(output)
280 }
281 }
282
283 #[cfg(test)]
285 pub(crate) fn push_note(
286 &mut self,
287 kind: &'static str,
288 content: impl Into<String>,
289 estimated_tokens: usize,
290 ) {
291 self.pending_notes.push(LspNote {
292 kind,
293 content: content.into(),
294 estimated_tokens,
295 });
296 }
297}
298
299#[cfg(test)]
300mod tests {
301 use std::sync::Arc;
302
303 use zeph_mcp::McpManager;
304 use zeph_memory::TokenCounter;
305
306 use super::*;
307 use crate::agent::agent_supervisor::BackgroundSupervisor;
308 use crate::config::{DiagnosticSeverity, LspConfig};
309
310 fn make_supervisor() -> BackgroundSupervisor {
311 BackgroundSupervisor::new(&zeph_config::TaskSupervisorConfig::default(), None)
312 }
313
314 fn make_runner(enabled: bool) -> LspHookRunner {
315 let enforcer = zeph_mcp::PolicyEnforcer::new(vec![]);
316 let manager = Arc::new(McpManager::new(vec![], vec![], enforcer));
317 LspHookRunner::new(
318 manager,
319 LspConfig {
320 enabled,
321 token_budget: 500,
322 ..LspConfig::default()
323 },
324 )
325 }
326
327 #[test]
328 fn drain_notes_empty() {
329 let mut runner = make_runner(true);
330 let tc = Arc::new(TokenCounter::default());
331 assert!(runner.drain_notes(&tc).is_none());
332 }
333
334 #[test]
335 fn drain_notes_formats_correctly() {
336 let tc = Arc::new(TokenCounter::default());
337 let mut runner = make_runner(true);
338 let tokens = tc.count_tokens("hello world");
339 runner.pending_notes.push(LspNote {
340 kind: "diagnostics",
341 content: "hello world".into(),
342 estimated_tokens: tokens,
343 });
344 let result = runner.drain_notes(&tc).unwrap();
345 assert!(result.starts_with("[lsp diagnostics]\nhello world"));
346 }
347
348 #[test]
349 fn drain_notes_budget_enforcement() {
350 let tc = Arc::new(TokenCounter::default());
351 let enforcer = zeph_mcp::PolicyEnforcer::new(vec![]);
352 let manager = Arc::new(McpManager::new(vec![], vec![], enforcer));
353 let mut runner = LspHookRunner::new(
354 manager,
355 LspConfig {
356 enabled: true,
357 token_budget: 1, ..LspConfig::default()
359 },
360 );
361 runner.pending_notes.push(LspNote {
362 kind: "diagnostics",
363 content: "a very long diagnostic message that exceeds one token".into(),
364 estimated_tokens: 20,
365 });
366 let result = runner.drain_notes(&tc);
367 assert!(result.is_none());
369 assert_eq!(runner.stats.notes_dropped_budget, 1);
370 }
371
372 #[test]
373 fn lsp_config_defaults() {
374 let cfg = LspConfig::default();
375 assert!(!cfg.enabled);
376 assert_eq!(cfg.mcp_server_id, "mcpls");
377 assert_eq!(cfg.token_budget, 2000);
378 assert_eq!(cfg.call_timeout_secs, 5);
379 assert!(cfg.diagnostics.enabled);
380 assert!(!cfg.hover.enabled);
381 assert_eq!(cfg.diagnostics.min_severity, DiagnosticSeverity::Error);
382 }
383
384 #[test]
385 fn lsp_config_toml_parse() {
386 let toml_str = r#"
387 enabled = true
388 mcp_server_id = "my-lsp"
389 token_budget = 3000
390
391 [diagnostics]
392 enabled = true
393 max_per_file = 10
394 min_severity = "warning"
395
396 [hover]
397 enabled = true
398 max_symbols = 5
399 "#;
400 let cfg: LspConfig = toml::from_str(toml_str).expect("parse LspConfig");
401 assert!(cfg.enabled);
402 assert_eq!(cfg.mcp_server_id, "my-lsp");
403 assert_eq!(cfg.token_budget, 3000);
404 assert_eq!(cfg.diagnostics.max_per_file, 10);
405 assert_eq!(cfg.diagnostics.min_severity, DiagnosticSeverity::Warning);
406 assert!(cfg.hover.enabled);
407 assert_eq!(cfg.hover.max_symbols, 5);
408 }
409
410 #[tokio::test]
411 async fn after_tool_disabled_does_not_queue_notes() {
412 use zeph_sanitizer::{ContentIsolationConfig, ContentSanitizer};
413 let tc = Arc::new(TokenCounter::default());
414 let sanitizer = ContentSanitizer::new(&ContentIsolationConfig::default());
415 let mut runner = make_runner(false); let mut supervisor = make_supervisor();
417
418 let params = serde_json::json!({ "path": "src/main.rs" });
420 runner
421 .after_tool("write", ¶ms, "", &tc, &sanitizer, &mut supervisor)
422 .await;
423 assert!(runner.diagnostics_rxs.is_empty());
425 assert!(runner.pending_notes.is_empty());
426 }
427
428 #[tokio::test]
429 async fn after_tool_unavailable_skips_on_write() {
430 use zeph_sanitizer::{ContentIsolationConfig, ContentSanitizer};
431 let tc = Arc::new(TokenCounter::default());
432 let sanitizer = ContentSanitizer::new(&ContentIsolationConfig::default());
433 let mut runner = make_runner(true);
435 let mut supervisor = make_supervisor();
436 let params = serde_json::json!({ "path": "src/main.rs" });
437 runner
438 .after_tool("write", ¶ms, "", &tc, &sanitizer, &mut supervisor)
439 .await;
440 assert!(runner.diagnostics_rxs.is_empty());
442 }
443
444 #[test]
445 fn collect_background_diagnostics_multiple_writes() {
446 use tokio::sync::mpsc;
447 let mut runner = make_runner(true);
448 let tc = Arc::new(TokenCounter::default());
449
450 for i in 0..2u64 {
452 let (tx, rx) = mpsc::channel(1);
453 runner.diagnostics_rxs.push(rx);
454 let note = LspNote {
455 kind: "diagnostics",
456 content: format!("error {i}"),
457 estimated_tokens: 5,
458 };
459 tx.try_send(Some(note)).unwrap();
460 }
461
462 runner.collect_background_diagnostics();
463 assert_eq!(runner.pending_notes.len(), 2);
465 assert_eq!(runner.stats.diagnostics_injected, 2);
466 assert!(runner.diagnostics_rxs.is_empty());
467
468 let result = runner.drain_notes(&tc).unwrap();
469 assert!(result.contains("error 0"));
470 assert!(result.contains("error 1"));
471 }
472}