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