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