返回 DeepSeek-TUI-2026
mod.rs
根目录 / crates / tui / src / lsp / mod.rs
1 //! LSP integration: post-edit diagnostics injection (#136).
2 //!
3 //! After the agent performs a successful file edit (`edit_file`,
4 //! `apply_patch`, or `write_file`) the engine asks the [`LspManager`] for
5 //! diagnostics on that file. The manager spawns the appropriate LSP server
6 //! lazily on first use, sends `didOpen`/`didChange`, waits up to a bounded
7 //! timeout for `publishDiagnostics`, normalizes the result, and returns it
8 //! to the engine.
9 //!
10 //! Failure modes are non-blocking by design: a missing LSP binary, a
11 //! crashed server, or a timeout all degrade to "no diagnostics this turn"
12 //! rather than stalling the agent. We log a one-time warning per language
13 //! when the binary is missing.
14 //!
15 //! # Wiring
16 //!
17 //! ```text
18 //! Engine ── after successful edit ──▶ LspManager.diagnostics_for(path, seq)
19 //! │
20 //! ▼
21 //! per-language LspClient
22 //! │
23 //! ▼
24 //! LspTransport (stdio)
25 //! ```
26 //!
27 //! # Configuration
28 //!
29 //! The `[lsp]` table in `~/.deepseek/config.toml` controls behavior:
30 //! `enabled`, `poll_after_edit_ms`, `max_diagnostics_per_file`,
31 //! `include_warnings`, and an optional `servers` override. See
32 //! [`LspConfig`] for defaults and `config.example.toml` for documentation.
33
34 use std::collections::{HashMap, HashSet};
35 use std::path::{Path, PathBuf};
36 use std::sync::Arc;
37 use std::time::Duration;
38
39 use serde::Deserialize;
40 use tokio::sync::Mutex as AsyncMutex;
41 use tokio::time::timeout;
42
43 pub mod client;
44 pub mod diagnostics;
45 pub mod registry;
46
47 pub use client::{LspTransport, StdioLspTransport};
48 pub use diagnostics::{Diagnostic, DiagnosticBlock, Severity, render_blocks};
49 pub use registry::Language;
50
51 /// `[lsp]` config schema. Mirrors the TOML keys documented in
52 /// `config.example.toml`. Unknown keys are ignored.
53 #[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
54 #[serde(default)]
55 pub struct LspConfig {
56 /// Master switch. When `false`, the manager skips every operation and
57 /// returns an empty diagnostics list.
58 pub enabled: bool,
59 /// Maximum time in milliseconds to wait for the LSP server to publish
60 /// diagnostics after a `didOpen`/`didChange`. Default 5000 ms.
61 pub poll_after_edit_ms: u64,
62 /// Maximum diagnostics to keep per file. Excess items are dropped after
63 /// sorting by severity. Default 20.
64 pub max_diagnostics_per_file: usize,
65 /// When `true`, warnings (severity 2) are kept in the output. When
66 /// `false` (default), only errors (severity 1) are surfaced.
67 pub include_warnings: bool,
68 /// Optional override for the `Language -> (cmd, args)` table. Keys use
69 /// [`Language::as_key`] (e.g. `"rust"`).
70 pub servers: HashMap<String, Vec<String>>,
71 }
72
73 impl Default for LspConfig {
74 fn default() -> Self {
75 Self {
76 enabled: true,
77 poll_after_edit_ms: 5_000,
78 max_diagnostics_per_file: 20,
79 include_warnings: false,
80 servers: HashMap::new(),
81 }
82 }
83 }
84
85 impl LspConfig {
86 /// Resolve `(command, args)` for `lang`. User-supplied overrides take
87 /// precedence over the built-in registry.
88 fn resolve_command(&self, lang: Language) -> Option<(String, Vec<String>)> {
89 if let Some(parts) = self.servers.get(lang.as_key())
90 && let Some((first, rest)) = parts.split_first()
91 {
92 return Some((first.clone(), rest.to_vec()));
93 }
94 let (cmd, args) = registry::server_for(lang)?;
95 Some((
96 cmd.to_string(),
97 args.iter().map(|a| (*a).to_string()).collect(),
98 ))
99 }
100 }
101
102 /// The LspManager holds a lazily populated map of `Language -> Transport`.
103 /// One transport is reused across files of the same language for the
104 /// session's lifetime.
105 pub struct LspManager {
106 config: LspConfig,
107 workspace: PathBuf,
108 /// Per-language transports. Wrapped in `Arc` so we can release the outer
109 /// lock before driving I/O on a single transport.
110 transports: AsyncMutex<HashMap<Language, Arc<dyn LspTransport>>>,
111 /// Per-language "we already warned the user that the binary is missing"
112 /// guard so we do not spam the audit log on every edit.
113 missing_warned: AsyncMutex<HashSet<Language>>,
114 /// Test seam: when set, `diagnostics_for` uses these instead of spawning
115 /// real LSP processes. Keyed by language.
116 test_transports: AsyncMutex<HashMap<Language, Arc<dyn LspTransport>>>,
117 }
118
119 impl LspManager {
120 /// Build a new manager. Does not spawn any LSP servers — that is lazy.
121 #[must_use]
122 pub fn new(config: LspConfig, workspace: PathBuf) -> Self {
123 Self {
124 config,
125 workspace,
126 transports: AsyncMutex::new(HashMap::new()),
127 missing_warned: AsyncMutex::new(HashSet::new()),
128 test_transports: AsyncMutex::new(HashMap::new()),
129 }
130 }
131
132 /// Read-only access to the resolved config. Used by the engine to skip
133 /// the post-edit hook entirely when `enabled = false`.
134 #[must_use]
135 pub fn config(&self) -> &LspConfig {
136 &self.config
137 }
138
139 /// Inject a fake transport for a language. Used by tests so we never
140 /// fork a real LSP server in CI.
141 #[cfg(test)]
142 pub async fn install_test_transport(&self, lang: Language, transport: Arc<dyn LspTransport>) {
143 self.test_transports.lock().await.insert(lang, transport);
144 }
145
146 /// Poll the LSP server for diagnostics on `file`. Returns the rendered
147 /// [`DiagnosticBlock`] (already truncated to the configured per-file
148 /// max) or `None` when the manager is disabled / has no server / the
149 /// poll times out.
150 ///
151 /// The `_edit_seq` argument is currently a no-op; it exists in the
152 /// signature so the engine can correlate diagnostics back to a specific
153 /// edit when we add request batching in v0.7.x.
154 pub async fn diagnostics_for(&self, file: &Path, _edit_seq: u64) -> Option<DiagnosticBlock> {
155 if !self.config.enabled {
156 return None;
157 }
158 let lang = registry::detect_language(file);
159 if lang == Language::Other {
160 return None;
161 }
162
163 let text = match tokio::fs::read_to_string(file).await {
164 Ok(text) => text,
165 Err(err) => {
166 tracing::debug!(?err, file = %file.display(), "lsp: read file failed");
167 return None;
168 }
169 };
170
171 let transport = match self.transport_for(lang).await {
172 Some(t) => t,
173 None => return None,
174 };
175
176 let wait = Duration::from_millis(self.config.poll_after_edit_ms);
177 let inner_wait = wait;
178 let raw = match timeout(wait, transport.diagnostics_for(file, &text, inner_wait)).await {
179 Ok(Ok(items)) => items,
180 Ok(Err(err)) => {
181 tracing::debug!(?err, file = %file.display(), "lsp: diagnostics call failed");
182 return None;
183 }
184 Err(_) => {
185 tracing::debug!(file = %file.display(), "lsp: diagnostics timed out");
186 return None;
187 }
188 };
189
190 // Filter, sort, and truncate.
191 let include_warnings = self.config.include_warnings;
192 let mut items: Vec<Diagnostic> = raw
193 .into_iter()
194 .filter(|d| match d.severity {
195 Severity::Error => true,
196 Severity::Warning => include_warnings,
197 _ => false,
198 })
199 .collect();
200 items.sort_by_key(|d| match d.severity {
201 Severity::Error => 0u8,
202 Severity::Warning => 1u8,
203 Severity::Information => 2u8,
204 Severity::Hint => 3u8,
205 });
206 let mut block = DiagnosticBlock {
207 file: relative_to_workspace(&self.workspace, file),
208 items,
209 };
210 block.truncate(self.config.max_diagnostics_per_file);
211 if block.items.is_empty() {
212 None
213 } else {
214 Some(block)
215 }
216 }
217
218 /// Resolve (and lazily spawn) the transport for `lang`. Tests can
219 /// short-circuit this via `install_test_transport` (cfg-test only).
220 async fn transport_for(&self, lang: Language) -> Option<Arc<dyn LspTransport>> {
221 if let Some(t) = self.test_transports.lock().await.get(&lang) {
222 return Some(t.clone());
223 }
224
225 if let Some(t) = self.transports.lock().await.get(&lang) {
226 return Some(t.clone());
227 }
228
229 let (cmd, args) = self.config.resolve_command(lang)?;
230 match StdioLspTransport::spawn(&cmd, &args, lang, self.workspace.clone()).await {
231 Ok(transport) => {
232 let arc: Arc<dyn LspTransport> = Arc::new(transport);
233 self.transports.lock().await.insert(lang, arc.clone());
234 Some(arc)
235 }
236 Err(err) => {
237 self.warn_missing_once(lang, &cmd, &err).await;
238 None
239 }
240 }
241 }
242
243 async fn warn_missing_once(&self, lang: Language, cmd: &str, err: &anyhow::Error) {
244 let mut warned = self.missing_warned.lock().await;
245 if warned.insert(lang) {
246 tracing::warn!(
247 language = %lang.as_key(),
248 command = %cmd,
249 error = %err,
250 "lsp: server unavailable; diagnostics disabled for this language"
251 );
252 }
253 }
254
255 /// Best-effort shutdown of every spawned transport. Called when the
256 /// session ends.
257 #[allow(dead_code)]
258 pub async fn shutdown_all(&self) {
259 let transports: Vec<Arc<dyn LspTransport>> =
260 self.transports.lock().await.values().cloned().collect();
261 for transport in transports {
262 transport.shutdown().await;
263 }
264 }
265 }
266
267 /// Render `path` relative to the workspace when possible. Falls back to
268 /// `path.file_name()` (per the issue's hard rule about not using
269 /// `display().to_string()` on the bare path) when relativization fails.
270 fn relative_to_workspace(workspace: &Path, path: &Path) -> PathBuf {
271 if let Ok(rel) = path.strip_prefix(workspace) {
272 return rel.to_path_buf();
273 }
274 PathBuf::from(
275 path.file_name()
276 .map(|n| n.to_string_lossy().into_owned())
277 .unwrap_or_else(|| String::from("unknown")),
278 )
279 }
280
281 /// Used for tests / no-op runs. Builds an empty manager that always returns
282 /// `None`. Needed because the engine constructs an `LspManager` even when
283 /// the user has disabled LSP, so the field is always present.
284 impl LspManager {
285 #[must_use]
286 pub fn disabled() -> Self {
287 Self::new(
288 LspConfig {
289 enabled: false,
290 ..LspConfig::default()
291 },
292 PathBuf::new(),
293 )
294 }
295 }
296
297 #[cfg(test)]
298 pub(crate) mod tests {
299 use super::*;
300 use async_trait::async_trait;
301 use std::sync::atomic::{AtomicUsize, Ordering};
302
303 /// Fake transport: returns a fixed list of diagnostics. Used by
304 /// integration tests so we never spawn a real LSP server in CI.
305 pub(crate) struct FakeTransport {
306 items: Vec<Diagnostic>,
307 calls: AtomicUsize,
308 }
309
310 impl FakeTransport {
311 pub(crate) fn new(items: Vec<Diagnostic>) -> Self {
312 Self {
313 items,
314 calls: AtomicUsize::new(0),
315 }
316 }
317
318 pub(crate) fn call_count(&self) -> usize {
319 self.calls.load(Ordering::Relaxed)
320 }
321 }
322
323 #[async_trait]
324 impl LspTransport for FakeTransport {
325 async fn diagnostics_for(
326 &self,
327 _path: &Path,
328 _text: &str,
329 _wait: Duration,
330 ) -> anyhow::Result<Vec<Diagnostic>> {
331 self.calls.fetch_add(1, Ordering::Relaxed);
332 Ok(self.items.clone())
333 }
334
335 async fn shutdown(&self) {}
336 }
337
338 #[tokio::test]
339 async fn returns_none_when_disabled() {
340 let mgr = LspManager::new(
341 LspConfig {
342 enabled: false,
343 ..LspConfig::default()
344 },
345 PathBuf::from("/tmp"),
346 );
347 let dir = tempfile::tempdir().unwrap();
348 let path = dir.path().join("foo.rs");
349 tokio::fs::write(&path, b"fn main() {}").await.unwrap();
350 assert!(mgr.diagnostics_for(&path, 1).await.is_none());
351 }
352
353 #[tokio::test]
354 async fn returns_none_for_unknown_language() {
355 let dir = tempfile::tempdir().unwrap();
356 let mgr = LspManager::new(LspConfig::default(), dir.path().to_path_buf());
357 let path = dir.path().join("notes.txt");
358 tokio::fs::write(&path, b"hi").await.unwrap();
359 assert!(mgr.diagnostics_for(&path, 1).await.is_none());
360 }
361
362 #[tokio::test]
363 async fn forwards_errors_through_fake_transport() {
364 let dir = tempfile::tempdir().unwrap();
365 let mgr = LspManager::new(LspConfig::default(), dir.path().to_path_buf());
366 let path = dir.path().join("foo.rs");
367 tokio::fs::write(&path, b"let x: i32 = \"oops\";")
368 .await
369 .unwrap();
370
371 let fake = Arc::new(FakeTransport::new(vec![Diagnostic {
372 line: 1,
373 column: 14,
374 severity: Severity::Error,
375 message: "expected i32, found &str".to_string(),
376 }]));
377 mgr.install_test_transport(Language::Rust, fake.clone())
378 .await;
379
380 let block = mgr.diagnostics_for(&path, 1).await.expect("has block");
381 let rendered = block.render();
382 assert!(rendered.contains("ERROR [1:14] expected i32, found &str"));
383 assert!(rendered.contains("foo.rs"));
384 assert_eq!(fake.call_count(), 1);
385 }
386
387 #[tokio::test]
388 async fn drops_warnings_by_default() {
389 let dir = tempfile::tempdir().unwrap();
390 let mgr = LspManager::new(LspConfig::default(), dir.path().to_path_buf());
391 let path = dir.path().join("foo.rs");
392 tokio::fs::write(&path, b"fn main() {}").await.unwrap();
393
394 let fake = Arc::new(FakeTransport::new(vec![
395 Diagnostic {
396 line: 1,
397 column: 1,
398 severity: Severity::Warning,
399 message: "unused import".to_string(),
400 },
401 Diagnostic {
402 line: 2,
403 column: 1,
404 severity: Severity::Error,
405 message: "type error".to_string(),
406 },
407 ]));
408 mgr.install_test_transport(Language::Rust, fake).await;
409
410 let block = mgr.diagnostics_for(&path, 1).await.expect("has block");
411 assert_eq!(block.items.len(), 1);
412 assert_eq!(block.items[0].severity, Severity::Error);
413 }
414
415 #[tokio::test]
416 async fn keeps_warnings_when_opted_in() {
417 let dir = tempfile::tempdir().unwrap();
418 let mgr = LspManager::new(
419 LspConfig {
420 include_warnings: true,
421 ..LspConfig::default()
422 },
423 dir.path().to_path_buf(),
424 );
425 let path = dir.path().join("foo.rs");
426 tokio::fs::write(&path, b"fn main() {}").await.unwrap();
427
428 let fake = Arc::new(FakeTransport::new(vec![
429 Diagnostic {
430 line: 1,
431 column: 1,
432 severity: Severity::Warning,
433 message: "unused".to_string(),
434 },
435 Diagnostic {
436 line: 2,
437 column: 1,
438 severity: Severity::Error,
439 message: "broken".to_string(),
440 },
441 ]));
442 mgr.install_test_transport(Language::Rust, fake).await;
443
444 let block = mgr.diagnostics_for(&path, 1).await.expect("has block");
445 assert_eq!(block.items.len(), 2);
446 // Errors come first after sorting.
447 assert_eq!(block.items[0].severity, Severity::Error);
448 assert_eq!(block.items[1].severity, Severity::Warning);
449 }
450
451 #[tokio::test]
452 async fn truncates_to_max_per_file() {
453 let dir = tempfile::tempdir().unwrap();
454 let mgr = LspManager::new(
455 LspConfig {
456 max_diagnostics_per_file: 3,
457 ..LspConfig::default()
458 },
459 dir.path().to_path_buf(),
460 );
461 let path = dir.path().join("foo.rs");
462 tokio::fs::write(&path, b"fn main() {}").await.unwrap();
463
464 let fake = Arc::new(FakeTransport::new(
465 (0..10)
466 .map(|i| Diagnostic {
467 line: i + 1,
468 column: 1,
469 severity: Severity::Error,
470 message: format!("err {i}"),
471 })
472 .collect(),
473 ));
474 mgr.install_test_transport(Language::Rust, fake).await;
475
476 let block = mgr.diagnostics_for(&path, 1).await.expect("has block");
477 assert_eq!(block.items.len(), 3);
478 }
479
480 #[tokio::test]
481 async fn render_blocks_concatenates() {
482 let blocks = vec![
483 DiagnosticBlock {
484 file: PathBuf::from("a.rs"),
485 items: vec![Diagnostic {
486 line: 1,
487 column: 1,
488 severity: Severity::Error,
489 message: "err in a".to_string(),
490 }],
491 },
492 DiagnosticBlock {
493 file: PathBuf::from("b.rs"),
494 items: vec![Diagnostic {
495 line: 2,
496 column: 2,
497 severity: Severity::Error,
498 message: "err in b".to_string(),
499 }],
500 },
501 ];
502 let rendered = render_blocks(&blocks);
503 assert!(rendered.contains("file=\"a.rs\""));
504 assert!(rendered.contains("file=\"b.rs\""));
505 }
506
507 #[test]
508 fn relative_path_falls_back_to_filename_when_outside_workspace() {
509 let workspace = PathBuf::from("/foo/bar");
510 let path = PathBuf::from("/baz/qux.rs");
511 assert_eq!(
512 relative_to_workspace(&workspace, &path),
513 PathBuf::from("qux.rs")
514 );
515 }
516
517 #[test]
518 fn config_resolve_uses_overrides() {
519 let mut cfg = LspConfig::default();
520 cfg.servers.insert(
521 "rust".to_string(),
522 vec!["custom-rls".to_string(), "--lsp".to_string()],
523 );
524 let (cmd, args) = cfg.resolve_command(Language::Rust).unwrap();
525 assert_eq!(cmd, "custom-rls");
526 assert_eq!(args, vec!["--lsp".to_string()]);
527 }
528
529 #[test]
530 fn config_resolve_falls_back_to_registry() {
531 let cfg = LspConfig::default();
532 let (cmd, _) = cfg.resolve_command(Language::Rust).unwrap();
533 assert_eq!(cmd, "rust-analyzer");
534 }
535 }
536
536 lines RUST