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 self.manager
92 .list_servers()
93 .await
94 .contains(&self.config.mcp_server_id)
95 }
96
97 pub async fn after_tool(
105 &mut self,
106 tool_name: &str,
107 tool_params: &serde_json::Value,
108 tool_output: &str,
109 token_counter: &Arc<zeph_memory::TokenCounter>,
110 sanitizer: &zeph_sanitizer::ContentSanitizer,
111 ) {
112 if !self.config.enabled {
113 tracing::debug!(tool = tool_name, "LSP hook: skipped (disabled)");
114 return;
115 }
116 if !self.is_available().await {
117 tracing::debug!(tool = tool_name, "LSP hook: skipped (server unavailable)");
118 return;
119 }
120
121 match tool_name {
122 "write" if self.config.diagnostics.enabled => {
123 self.spawn_diagnostics_fetch(tool_params, token_counter, sanitizer);
124 }
125 "read" if self.config.hover.enabled => {
126 if let Some(note) =
127 hover::fetch_hover(self, tool_params, tool_output, token_counter, sanitizer)
128 .await
129 {
130 self.stats.hover_injected += 1;
131 self.pending_notes.push(note);
132 }
133 }
134 "write" => {
135 tracing::debug!(tool = tool_name, "LSP hook: skipped (diagnostics disabled)");
136 }
137 "read" => {
138 tracing::debug!(tool = tool_name, "LSP hook: skipped (hover disabled)");
139 }
140 _ => {}
141 }
142 }
143
144 fn spawn_diagnostics_fetch(
154 &mut self,
155 tool_params: &serde_json::Value,
156 token_counter: &Arc<zeph_memory::TokenCounter>,
157 sanitizer: &zeph_sanitizer::ContentSanitizer,
158 ) {
159 let Some(path) = tool_params
160 .get("path")
161 .and_then(|v| v.as_str())
162 .map(ToOwned::to_owned)
163 else {
164 tracing::debug!("LSP hook: skipped diagnostics fetch (missing path)");
165 return;
166 };
167
168 tracing::debug!(tool = "write", path = %path, "LSP hook: spawning diagnostics fetch");
169
170 let manager = Arc::clone(&self.manager);
171 let config = self.config.clone();
172 let tc = Arc::clone(token_counter);
173 let sanitizer = sanitizer.clone();
174
175 let (tx, rx) = mpsc::channel(1);
176 self.diagnostics_rxs.push(rx);
177
178 tokio::spawn(async move {
179 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
183
184 let note =
185 diagnostics::fetch_diagnostics(manager.as_ref(), &config, &path, &tc, &sanitizer)
186 .await;
187 let _ = tx.send(note).await;
190 });
191 }
192
193 fn collect_background_diagnostics(&mut self) {
198 let mut still_pending = Vec::new();
199 for mut rx in self.diagnostics_rxs.drain(..) {
200 match rx.try_recv() {
201 Ok(Some(note)) => {
202 self.stats.diagnostics_injected += 1;
203 self.pending_notes.push(note);
204 }
205 Ok(None) | Err(mpsc::error::TryRecvError::Disconnected) => {
206 }
208 Err(mpsc::error::TryRecvError::Empty) => {
209 still_pending.push(rx);
211 }
212 }
213 }
214 self.diagnostics_rxs = still_pending;
215 }
216
217 #[must_use]
222 pub fn drain_notes(
223 &mut self,
224 token_counter: &Arc<zeph_memory::TokenCounter>,
225 ) -> Option<String> {
226 use std::fmt::Write as _;
227 self.collect_background_diagnostics();
228
229 if self.pending_notes.is_empty() {
230 return None;
231 }
232
233 let mut output = String::new();
234 let mut remaining = self.config.token_budget;
235
236 for note in self.pending_notes.drain(..) {
237 if note.estimated_tokens > remaining {
238 tracing::debug!(
239 kind = note.kind,
240 tokens = note.estimated_tokens,
241 remaining,
242 "LSP note dropped: token budget exceeded"
243 );
244 self.stats.notes_dropped_budget += 1;
245 continue;
246 }
247 remaining -= note.estimated_tokens;
248 if !output.is_empty() {
249 output.push('\n');
250 }
251 let _ = write!(output, "[lsp {}]\n{}", note.kind, note.content);
252 }
253
254 if output.is_empty() {
256 None
257 } else {
258 let _ = token_counter; Some(output)
260 }
261 }
262}
263
264#[cfg(test)]
265mod tests {
266 use std::sync::Arc;
267
268 use zeph_mcp::McpManager;
269 use zeph_memory::TokenCounter;
270
271 use super::*;
272 use crate::config::{DiagnosticSeverity, LspConfig};
273
274 fn make_runner(enabled: bool) -> LspHookRunner {
275 let enforcer = zeph_mcp::PolicyEnforcer::new(vec![]);
276 let manager = Arc::new(McpManager::new(vec![], vec![], enforcer));
277 LspHookRunner::new(
278 manager,
279 LspConfig {
280 enabled,
281 token_budget: 500,
282 ..LspConfig::default()
283 },
284 )
285 }
286
287 #[test]
288 fn drain_notes_empty() {
289 let mut runner = make_runner(true);
290 let tc = Arc::new(TokenCounter::default());
291 assert!(runner.drain_notes(&tc).is_none());
292 }
293
294 #[test]
295 fn drain_notes_formats_correctly() {
296 let tc = Arc::new(TokenCounter::default());
297 let mut runner = make_runner(true);
298 let tokens = tc.count_tokens("hello world");
299 runner.pending_notes.push(LspNote {
300 kind: "diagnostics",
301 content: "hello world".into(),
302 estimated_tokens: tokens,
303 });
304 let result = runner.drain_notes(&tc).unwrap();
305 assert!(result.starts_with("[lsp diagnostics]\nhello world"));
306 }
307
308 #[test]
309 fn drain_notes_budget_enforcement() {
310 let tc = Arc::new(TokenCounter::default());
311 let enforcer = zeph_mcp::PolicyEnforcer::new(vec![]);
312 let manager = Arc::new(McpManager::new(vec![], vec![], enforcer));
313 let mut runner = LspHookRunner::new(
314 manager,
315 LspConfig {
316 enabled: true,
317 token_budget: 1, ..LspConfig::default()
319 },
320 );
321 runner.pending_notes.push(LspNote {
322 kind: "diagnostics",
323 content: "a very long diagnostic message that exceeds one token".into(),
324 estimated_tokens: 20,
325 });
326 let result = runner.drain_notes(&tc);
327 assert!(result.is_none());
329 assert_eq!(runner.stats.notes_dropped_budget, 1);
330 }
331
332 #[test]
333 fn lsp_config_defaults() {
334 let cfg = LspConfig::default();
335 assert!(!cfg.enabled);
336 assert_eq!(cfg.mcp_server_id, "mcpls");
337 assert_eq!(cfg.token_budget, 2000);
338 assert_eq!(cfg.call_timeout_secs, 5);
339 assert!(cfg.diagnostics.enabled);
340 assert!(!cfg.hover.enabled);
341 assert_eq!(cfg.diagnostics.min_severity, DiagnosticSeverity::Error);
342 }
343
344 #[test]
345 fn lsp_config_toml_parse() {
346 let toml_str = r#"
347 enabled = true
348 mcp_server_id = "my-lsp"
349 token_budget = 3000
350
351 [diagnostics]
352 enabled = true
353 max_per_file = 10
354 min_severity = "warning"
355
356 [hover]
357 enabled = true
358 max_symbols = 5
359 "#;
360 let cfg: LspConfig = toml::from_str(toml_str).expect("parse LspConfig");
361 assert!(cfg.enabled);
362 assert_eq!(cfg.mcp_server_id, "my-lsp");
363 assert_eq!(cfg.token_budget, 3000);
364 assert_eq!(cfg.diagnostics.max_per_file, 10);
365 assert_eq!(cfg.diagnostics.min_severity, DiagnosticSeverity::Warning);
366 assert!(cfg.hover.enabled);
367 assert_eq!(cfg.hover.max_symbols, 5);
368 }
369
370 #[tokio::test]
371 async fn after_tool_disabled_does_not_queue_notes() {
372 use zeph_sanitizer::{ContentIsolationConfig, ContentSanitizer};
373 let tc = Arc::new(TokenCounter::default());
374 let sanitizer = ContentSanitizer::new(&ContentIsolationConfig::default());
375 let mut runner = make_runner(false); let params = serde_json::json!({ "path": "src/main.rs" });
379 runner
380 .after_tool("write", ¶ms, "", &tc, &sanitizer)
381 .await;
382 assert!(runner.diagnostics_rxs.is_empty());
384 assert!(runner.pending_notes.is_empty());
385 }
386
387 #[tokio::test]
388 async fn after_tool_unavailable_skips_on_write() {
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(true);
394 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 }
401
402 #[test]
403 fn collect_background_diagnostics_multiple_writes() {
404 use tokio::sync::mpsc;
405 let mut runner = make_runner(true);
406 let tc = Arc::new(TokenCounter::default());
407
408 for i in 0..2u64 {
410 let (tx, rx) = mpsc::channel(1);
411 runner.diagnostics_rxs.push(rx);
412 let note = LspNote {
413 kind: "diagnostics",
414 content: format!("error {i}"),
415 estimated_tokens: 5,
416 };
417 tx.try_send(Some(note)).unwrap();
418 }
419
420 runner.collect_background_diagnostics();
421 assert_eq!(runner.pending_notes.len(), 2);
423 assert_eq!(runner.stats.diagnostics_injected, 2);
424 assert!(runner.diagnostics_rxs.is_empty());
425
426 let result = runner.drain_notes(&tc).unwrap();
427 assert!(result.contains("error 0"));
428 assert!(result.contains("error 1"));
429 }
430}