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
280#[cfg(test)]
281mod tests {
282 use std::sync::Arc;
283
284 use zeph_mcp::McpManager;
285 use zeph_memory::TokenCounter;
286
287 use super::*;
288 use crate::config::{DiagnosticSeverity, LspConfig};
289
290 fn make_runner(enabled: bool) -> LspHookRunner {
291 let enforcer = zeph_mcp::PolicyEnforcer::new(vec![]);
292 let manager = Arc::new(McpManager::new(vec![], vec![], enforcer));
293 LspHookRunner::new(
294 manager,
295 LspConfig {
296 enabled,
297 token_budget: 500,
298 ..LspConfig::default()
299 },
300 )
301 }
302
303 #[test]
304 fn drain_notes_empty() {
305 let mut runner = make_runner(true);
306 let tc = Arc::new(TokenCounter::default());
307 assert!(runner.drain_notes(&tc).is_none());
308 }
309
310 #[test]
311 fn drain_notes_formats_correctly() {
312 let tc = Arc::new(TokenCounter::default());
313 let mut runner = make_runner(true);
314 let tokens = tc.count_tokens("hello world");
315 runner.pending_notes.push(LspNote {
316 kind: "diagnostics",
317 content: "hello world".into(),
318 estimated_tokens: tokens,
319 });
320 let result = runner.drain_notes(&tc).unwrap();
321 assert!(result.starts_with("[lsp diagnostics]\nhello world"));
322 }
323
324 #[test]
325 fn drain_notes_budget_enforcement() {
326 let tc = Arc::new(TokenCounter::default());
327 let enforcer = zeph_mcp::PolicyEnforcer::new(vec![]);
328 let manager = Arc::new(McpManager::new(vec![], vec![], enforcer));
329 let mut runner = LspHookRunner::new(
330 manager,
331 LspConfig {
332 enabled: true,
333 token_budget: 1, ..LspConfig::default()
335 },
336 );
337 runner.pending_notes.push(LspNote {
338 kind: "diagnostics",
339 content: "a very long diagnostic message that exceeds one token".into(),
340 estimated_tokens: 20,
341 });
342 let result = runner.drain_notes(&tc);
343 assert!(result.is_none());
345 assert_eq!(runner.stats.notes_dropped_budget, 1);
346 }
347
348 #[test]
349 fn lsp_config_defaults() {
350 let cfg = LspConfig::default();
351 assert!(!cfg.enabled);
352 assert_eq!(cfg.mcp_server_id, "mcpls");
353 assert_eq!(cfg.token_budget, 2000);
354 assert_eq!(cfg.call_timeout_secs, 5);
355 assert!(cfg.diagnostics.enabled);
356 assert!(!cfg.hover.enabled);
357 assert_eq!(cfg.diagnostics.min_severity, DiagnosticSeverity::Error);
358 }
359
360 #[test]
361 fn lsp_config_toml_parse() {
362 let toml_str = r#"
363 enabled = true
364 mcp_server_id = "my-lsp"
365 token_budget = 3000
366
367 [diagnostics]
368 enabled = true
369 max_per_file = 10
370 min_severity = "warning"
371
372 [hover]
373 enabled = true
374 max_symbols = 5
375 "#;
376 let cfg: LspConfig = toml::from_str(toml_str).expect("parse LspConfig");
377 assert!(cfg.enabled);
378 assert_eq!(cfg.mcp_server_id, "my-lsp");
379 assert_eq!(cfg.token_budget, 3000);
380 assert_eq!(cfg.diagnostics.max_per_file, 10);
381 assert_eq!(cfg.diagnostics.min_severity, DiagnosticSeverity::Warning);
382 assert!(cfg.hover.enabled);
383 assert_eq!(cfg.hover.max_symbols, 5);
384 }
385
386 #[tokio::test]
387 async fn after_tool_disabled_does_not_queue_notes() {
388 use zeph_sanitizer::{ContentIsolationConfig, ContentSanitizer};
389 let tc = Arc::new(TokenCounter::default());
390 let sanitizer = ContentSanitizer::new(&ContentIsolationConfig::default());
391 let mut runner = make_runner(false); let params = serde_json::json!({ "path": "src/main.rs" });
395 runner
396 .after_tool("write", ¶ms, "", &tc, &sanitizer)
397 .await;
398 assert!(runner.diagnostics_rxs.is_empty());
400 assert!(runner.pending_notes.is_empty());
401 }
402
403 #[tokio::test]
404 async fn after_tool_unavailable_skips_on_write() {
405 use zeph_sanitizer::{ContentIsolationConfig, ContentSanitizer};
406 let tc = Arc::new(TokenCounter::default());
407 let sanitizer = ContentSanitizer::new(&ContentIsolationConfig::default());
408 let mut runner = make_runner(true);
410 let params = serde_json::json!({ "path": "src/main.rs" });
411 runner
412 .after_tool("write", ¶ms, "", &tc, &sanitizer)
413 .await;
414 assert!(runner.diagnostics_rxs.is_empty());
416 }
417
418 #[test]
419 fn collect_background_diagnostics_multiple_writes() {
420 use tokio::sync::mpsc;
421 let mut runner = make_runner(true);
422 let tc = Arc::new(TokenCounter::default());
423
424 for i in 0..2u64 {
426 let (tx, rx) = mpsc::channel(1);
427 runner.diagnostics_rxs.push(rx);
428 let note = LspNote {
429 kind: "diagnostics",
430 content: format!("error {i}"),
431 estimated_tokens: 5,
432 };
433 tx.try_send(Some(note)).unwrap();
434 }
435
436 runner.collect_background_diagnostics();
437 assert_eq!(runner.pending_notes.len(), 2);
439 assert_eq!(runner.stats.diagnostics_injected, 2);
440 assert!(runner.diagnostics_rxs.is_empty());
441
442 let result = runner.drain_notes(&tc).unwrap();
443 assert!(result.contains("error 0"));
444 assert!(result.contains("error 1"));
445 }
446}