返回 CodeWhale
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`, `include_warnings`,
31 //! an optional `servers` override, and a `custom` table for registering LSP
32 //! servers for file extensions not covered by the built-in registry (e.g. Ruby,
33 //! PHP, C#). See [`LspConfig`] for defaults and `config.example.toml` for
34 //! documentation.
35
36 use std::collections::{HashMap, HashSet};
37 use std::path::{Path, PathBuf};
38 use std::sync::Arc;
39 use std::time::Duration;
40
41 use serde::Deserialize;
42 use tokio::sync::Mutex as AsyncMutex;
43 use tokio::time::timeout;
44
45 pub mod client;
46 pub mod diagnostics;
47 pub mod registry;
48
49 pub use client::{LspTransport, StdioLspTransport};
50 pub use diagnostics::{Diagnostic, DiagnosticBlock, Severity, render_blocks};
51 pub use registry::Language;
52
53 /// User-defined LSP server for one file extension.
54 ///
55 /// Registered via `[lsp.custom.<ext>]` in the config. The extension key is the
56 /// file suffix (without the leading dot), e.g. `"php"`, `"rb"`, `"cs"`.
57 #[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
58 pub struct CustomLspDef {
59 /// LSP `languageId` value used in `textDocument/didOpen`.
60 pub language_id: String,
61 /// Executable to spawn.
62 pub command: String,
63 /// Arguments passed to the executable.
64 #[serde(default)]
65 pub args: Vec<String>,
66 }
67
68 /// `[lsp]` config schema. Mirrors the TOML keys documented in
69 /// `config.example.toml`. Unknown keys are ignored.
70 #[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
71 #[serde(default)]
72 pub struct LspConfig {
73 /// Master switch. When `false`, the manager skips every operation and
74 /// returns an empty diagnostics list.
75 pub enabled: bool,
76 /// Maximum time in milliseconds to wait for the LSP server to publish
77 /// diagnostics after a `didOpen`/`didChange`. Default 5000 ms.
78 pub poll_after_edit_ms: u64,
79 /// Maximum diagnostics to keep per file. Excess items are dropped after
80 /// sorting by severity. Default 20.
81 pub max_diagnostics_per_file: usize,
82 /// When `true`, warnings (severity 2) are kept in the output. When
83 /// `false` (default), only errors (severity 1) are surfaced.
84 pub include_warnings: bool,
85 /// Optional override for the `Language -> (cmd, args)` table. Keys use
86 /// [`Language::as_key`] (e.g. `"rust"`).
87 pub servers: HashMap<String, Vec<String>>,
88 /// User-defined LSP servers for file extensions not in the built-in
89 /// registry. Keyed by extension (e.g. `"php"`, `"rb"`).
90 #[serde(default)]
91 pub custom: HashMap<String, CustomLspDef>,
92 }
93
94 impl Default for LspConfig {
95 fn default() -> Self {
96 Self {
97 enabled: true,
98 poll_after_edit_ms: 5_000,
99 max_diagnostics_per_file: 20,
100 include_warnings: false,
101 servers: HashMap::new(),
102 custom: HashMap::new(),
103 }
104 }
105 }
106
107 impl LspConfig {
108 /// Resolve `(command, args)` for `lang`. User-supplied overrides take
109 /// precedence over the built-in registry.
110 fn resolve_command(&self, lang: Language) -> Option<(String, Vec<String>)> {
111 if let Some(parts) = self.servers.get(lang.as_key())
112 && let Some((first, rest)) = parts.split_first()
113 {
114 return Some((first.clone(), rest.to_vec()));
115 }
116 let (cmd, args) = registry::server_for(lang)?;
117 Some((
118 cmd.to_string(),
119 args.iter().map(|a| (*a).to_string()).collect(),
120 ))
121 }
122 }
123
124 /// The LspManager holds a lazily populated map of `Language -> Transport`.
125 /// One transport is reused across files of the same language for the
126 /// session's lifetime.
127 pub struct LspManager {
128 config: LspConfig,
129 workspace: PathBuf,
130 /// Per-language transports. Wrapped in `Arc` so we can release the outer
131 /// lock before driving I/O on a single transport.
132 transports: AsyncMutex<HashMap<Language, Arc<dyn LspTransport>>>,
133 /// Per-language "we already warned the user that the binary is missing"
134 /// guard so we do not spam the audit log on every edit.
135 missing_warned: AsyncMutex<HashSet<Language>>,
136 /// Test seam: when set, `diagnostics_for` uses these instead of spawning
137 /// real LSP processes. Keyed by language.
138 test_transports: AsyncMutex<HashMap<Language, Arc<dyn LspTransport>>>,
139 /// Per-extension transports for user-defined custom language servers.
140 custom_transports: AsyncMutex<HashMap<String, Arc<dyn LspTransport>>>,
141 /// Per-extension "we already warned" guard for custom servers.
142 custom_missing_warned: AsyncMutex<HashSet<String>>,
143 }
144
145 impl LspManager {
146 /// Build a new manager. Does not spawn any LSP servers — that is lazy.
147 #[must_use]
148 pub fn new(config: LspConfig, workspace: PathBuf) -> Self {
149 Self {
150 config,
151 workspace,
152 transports: AsyncMutex::new(HashMap::new()),
153 missing_warned: AsyncMutex::new(HashSet::new()),
154 test_transports: AsyncMutex::new(HashMap::new()),
155 custom_transports: AsyncMutex::new(HashMap::new()),
156 custom_missing_warned: AsyncMutex::new(HashSet::new()),
157 }
158 }
159
160 /// Read-only access to the resolved config. Used by the engine to skip
161 /// the post-edit hook entirely when `enabled = false`.
162 #[must_use]
163 pub fn config(&self) -> &LspConfig {
164 &self.config
165 }
166
167 /// Inject a fake transport for a language. Used by tests so we never
168 /// fork a real LSP server in CI.
169 #[cfg(test)]
170 pub async fn install_test_transport(&self, lang: Language, transport: Arc<dyn LspTransport>) {
171 self.test_transports.lock().await.insert(lang, transport);
172 }
173
174 /// Poll the LSP server for diagnostics on `file`. Returns the rendered
175 /// [`DiagnosticBlock`] (already truncated to the configured per-file
176 /// max) or `None` when the manager is disabled / has no server / the
177 /// poll times out.
178 ///
179 /// The `_edit_seq` argument is currently a no-op; it exists in the
180 /// signature so the engine can correlate diagnostics back to a specific
181 /// edit when we add request batching in v0.7.x.
182 pub async fn diagnostics_for(&self, file: &Path, _edit_seq: u64) -> Option<DiagnosticBlock> {
183 if !self.config.enabled {
184 return None;
185 }
186
187 let lang = registry::detect_language(file);
188 if lang == Language::Other {
189 // Custom extension fallback: check user-defined LSP servers
190 // for file extensions not covered by the built-in registry.
191 if let Some(custom) = self.config.custom_for_extension(file) {
192 return self.diagnostics_for_custom(file, custom).await;
193 }
194 return None;
195 }
196
197 let text = match tokio::fs::read_to_string(file).await {
198 Ok(text) => text,
199 Err(err) => {
200 tracing::debug!(?err, file = %file.display(), "lsp: read file failed");
201 return None;
202 }
203 };
204
205 let transport = match self.transport_for(lang).await {
206 Some(t) => t,
207 None => return None,
208 };
209
210 self.poll_diagnostics(file, &text, transport).await
211 }
212
213 /// Shared diagnostics polling: send didOpen/didChange, wait, filter,
214 /// sort, and truncate.
215 async fn poll_diagnostics(
216 &self,
217 file: &Path,
218 text: &str,
219 transport: Arc<dyn LspTransport>,
220 ) -> Option<DiagnosticBlock> {
221 let wait = Duration::from_millis(self.config.poll_after_edit_ms);
222 let inner_wait = wait;
223 let raw = match timeout(wait, transport.diagnostics_for(file, text, inner_wait)).await {
224 Ok(Ok(items)) => items,
225 Ok(Err(err)) => {
226 tracing::debug!(?err, file = %file.display(), "lsp: diagnostics call failed");
227 return None;
228 }
229 Err(_) => {
230 tracing::debug!(file = %file.display(), "lsp: diagnostics timed out");
231 return None;
232 }
233 };
234
235 // Filter, sort, and truncate.
236 let include_warnings = self.config.include_warnings;
237 let mut items: Vec<Diagnostic> = raw
238 .into_iter()
239 .filter(|d| match d.severity {
240 Severity::Error => true,
241 Severity::Warning => include_warnings,
242 _ => false,
243 })
244 .collect();
245 items.sort_by_key(|d| match d.severity {
246 Severity::Error => 0u8,
247 Severity::Warning => 1u8,
248 Severity::Information => 2u8,
249 Severity::Hint => 3u8,
250 });
251 let mut block = DiagnosticBlock {
252 file: relative_to_workspace(&self.workspace, file),
253 items,
254 };
255 block.truncate(self.config.max_diagnostics_per_file);
256 if block.items.is_empty() {
257 None
258 } else {
259 Some(block)
260 }
261 }
262
263 /// Diagnostics path for a user-defined custom language server.
264 async fn diagnostics_for_custom(
265 &self,
266 file: &Path,
267 custom: &CustomLspDef,
268 ) -> Option<DiagnosticBlock> {
269 let ext = file.extension()?.to_str()?.to_ascii_lowercase();
270 let text = match tokio::fs::read_to_string(file).await {
271 Ok(t) => t,
272 Err(err) => {
273 tracing::debug!(?err, file = %file.display(), "lsp: read file failed");
274 return None;
275 }
276 };
277 let transport = match self.transport_for_custom(&ext, custom).await {
278 Some(t) => t,
279 None => return None,
280 };
281 self.poll_diagnostics(file, &text, transport).await
282 }
283
284 /// Lazy-spawn a custom LSP server for an extension.
285 async fn transport_for_custom(
286 &self,
287 ext: &str,
288 def: &CustomLspDef,
289 ) -> Option<Arc<dyn LspTransport>> {
290 if let Some(t) = self.custom_transports.lock().await.get(ext) {
291 return Some(t.clone());
292 }
293 match StdioLspTransport::spawn(
294 &def.command,
295 &def.args,
296 &def.language_id,
297 self.workspace.clone(),
298 )
299 .await
300 {
301 Ok(t) => {
302 let arc: Arc<dyn LspTransport> = Arc::new(t);
303 self.custom_transports
304 .lock()
305 .await
306 .insert(ext.to_string(), arc.clone());
307 Some(arc)
308 }
309 Err(err) => {
310 let key = ext.to_string();
311 let mut warned = self.custom_missing_warned.lock().await;
312 if warned.insert(key) {
313 tracing::warn!(
314 extension = %ext,
315 command = %def.command,
316 error = %err,
317 "lsp: custom server unavailable; diagnostics disabled for this extension"
318 );
319 }
320 None
321 }
322 }
323 }
324
325 /// Resolve (and lazily spawn) the transport for `lang`. Tests can
326 /// short-circuit this via `install_test_transport` (cfg-test only).
327 async fn transport_for(&self, lang: Language) -> Option<Arc<dyn LspTransport>> {
328 if let Some(t) = self.test_transports.lock().await.get(&lang) {
329 return Some(t.clone());
330 }
331
332 if let Some(t) = self.transports.lock().await.get(&lang) {
333 return Some(t.clone());
334 }
335
336 let (cmd, args) = self.config.resolve_command(lang)?;
337 match StdioLspTransport::spawn(&cmd, &args, lang.language_id(), self.workspace.clone())
338 .await
339 {
340 Ok(transport) => {
341 let arc: Arc<dyn LspTransport> = Arc::new(transport);
342 self.transports.lock().await.insert(lang, arc.clone());
343 Some(arc)
344 }
345 Err(err) => {
346 self.warn_missing_once(lang, &cmd, &err).await;
347 None
348 }
349 }
350 }
351
352 async fn warn_missing_once(&self, lang: Language, cmd: &str, err: &anyhow::Error) {
353 let mut warned = self.missing_warned.lock().await;
354 if warned.insert(lang) {
355 tracing::warn!(
356 language = %lang.as_key(),
357 command = %cmd,
358 error = %err,
359 "lsp: server unavailable; diagnostics disabled for this language"
360 );
361 }
362 }
363
364 /// Resolve a transport for `file` without creating a second server
365 /// lifecycle. Reuses the same lazy map as post-edit diagnostics.
366 async fn transport_for_path(&self, file: &Path) -> Option<Arc<dyn LspTransport>> {
367 if !self.config.enabled {
368 return None;
369 }
370 let lang = registry::detect_language(file);
371 if lang != Language::Other {
372 return self.transport_for(lang).await;
373 }
374 if let Some(custom) = self.config.custom_for_extension(file) {
375 let ext = file.extension()?.to_str()?.to_ascii_lowercase();
376 return self.transport_for_custom(&ext, custom).await;
377 }
378 None
379 }
380
381 /// Model-facing intelligence query. Shares the existing transport pool.
382 /// `operation` is one of: `diagnostics`, `symbols`, `definition`, `references`.
383 pub async fn intelligence(
384 &self,
385 operation: &str,
386 file: &Path,
387 line: Option<u32>,
388 character: Option<u32>,
389 query: Option<&str>,
390 ) -> Result<serde_json::Value, String> {
391 if !self.config.enabled {
392 return Err("LSP is disabled ([lsp] enabled = false)".to_string());
393 }
394 let wait = Duration::from_millis(self.config.poll_after_edit_ms);
395 match operation {
396 "diagnostics" => {
397 let block = self
398 .diagnostics_for(file, 0)
399 .await
400 .map(|b| {
401 serde_json::json!({
402 "file": b.file.display().to_string(),
403 "items": b.items.iter().map(|d| serde_json::json!({
404 "line": d.line,
405 "column": d.column,
406 "severity": format!("{:?}", d.severity).to_ascii_lowercase(),
407 "message": d.message,
408 })).collect::<Vec<_>>(),
409 })
410 })
411 .unwrap_or_else(|| {
412 serde_json::json!({
413 "file": relative_to_workspace(&self.workspace, file).display().to_string(),
414 "items": [],
415 })
416 });
417 Ok(block)
418 }
419 "symbols" | "definition" | "references" => {
420 let transport = self
421 .transport_for_path(file)
422 .await
423 .ok_or_else(|| format!("no LSP server for {}", file.display()))?;
424 let text = tokio::fs::read_to_string(file)
425 .await
426 .map_err(|err| format!("read {}: {err}", file.display()))?;
427 transport
428 .ensure_open(file, &text)
429 .await
430 .map_err(|err| err.to_string())?;
431 let uri = client::uri_from_path(file);
432 let result = match operation {
433 "symbols" => {
434 if let Some(q) = query.filter(|s| !s.trim().is_empty()) {
435 transport
436 .request(
437 "workspace/symbol",
438 serde_json::json!({ "query": q }),
439 wait,
440 )
441 .await
442 } else {
443 transport
444 .request(
445 "textDocument/documentSymbol",
446 serde_json::json!({
447 "textDocument": { "uri": uri }
448 }),
449 wait,
450 )
451 .await
452 }
453 }
454 "definition" => {
455 let line = line.ok_or("definition requires line (1-based)")?;
456 let character = character.unwrap_or(1);
457 transport
458 .request(
459 "textDocument/definition",
460 serde_json::json!({
461 "textDocument": { "uri": uri },
462 "position": {
463 "line": line.saturating_sub(1),
464 "character": character.saturating_sub(1),
465 }
466 }),
467 wait,
468 )
469 .await
470 }
471 "references" => {
472 let line = line.ok_or("references requires line (1-based)")?;
473 let character = character.unwrap_or(1);
474 transport
475 .request(
476 "textDocument/references",
477 serde_json::json!({
478 "textDocument": { "uri": uri },
479 "position": {
480 "line": line.saturating_sub(1),
481 "character": character.saturating_sub(1),
482 },
483 "context": { "includeDeclaration": true }
484 }),
485 wait,
486 )
487 .await
488 }
489 _ => unreachable!(),
490 }
491 .map_err(|err| err.to_string())?;
492 Ok(serde_json::json!({
493 "operation": operation,
494 "file": relative_to_workspace(&self.workspace, file).display().to_string(),
495 "result": truncate_intelligence_result(result),
496 }))
497 }
498 other => Err(format!(
499 "unknown LSP operation '{other}'; use diagnostics, symbols, definition, or references"
500 )),
501 }
502 }
503
504 /// Best-effort shutdown of every spawned transport. Called when the
505 /// session ends.
506 #[allow(dead_code)]
507 pub async fn shutdown_all(&self) {
508 let transports: Vec<Arc<dyn LspTransport>> =
509 self.transports.lock().await.values().cloned().collect();
510 let custom: Vec<Arc<dyn LspTransport>> = self
511 .custom_transports
512 .lock()
513 .await
514 .values()
515 .cloned()
516 .collect();
517 for transport in transports {
518 transport.shutdown().await;
519 }
520 for transport in custom {
521 transport.shutdown().await;
522 }
523 }
524 }
525
526 impl LspConfig {
527 /// Look up a [`CustomLspDef`] for `file` when the built-in registry
528 /// would return `Language::Other`. Returns `None` when the extension is
529 /// unknown or no custom server is registered for it.
530 fn custom_for_extension(&self, file: &Path) -> Option<&CustomLspDef> {
531 let ext = file.extension()?.to_str()?;
532 self.custom.get(&ext.to_ascii_lowercase())
533 }
534 }
535
536 /// Cap intelligence payloads so a chatty language server cannot flood the
537 /// model context. Arrays keep the first `MAX` entries and set `truncated`.
538 fn truncate_intelligence_result(value: serde_json::Value) -> serde_json::Value {
539 const MAX_ITEMS: usize = 40;
540 const MAX_CHARS: usize = 12_000;
541 match value {
542 serde_json::Value::Array(mut items) => {
543 let total = items.len();
544 if total > MAX_ITEMS {
545 items.truncate(MAX_ITEMS);
546 serde_json::json!({
547 "items": items,
548 "truncated": true,
549 "total": total,
550 })
551 } else {
552 serde_json::Value::Array(items)
553 }
554 }
555 other => {
556 let rendered = other.to_string();
557 if rendered.len() > MAX_CHARS {
558 serde_json::json!({
559 "truncated": true,
560 "preview": &rendered[..MAX_CHARS],
561 "total_chars": rendered.len(),
562 })
563 } else {
564 other
565 }
566 }
567 }
568 }
569
570 /// Render `path` relative to the workspace when possible. Falls back to
571 /// `path.file_name()` (per the issue's hard rule about not using
572 /// `display().to_string()` on the bare path) when relativization fails.
573 fn relative_to_workspace(workspace: &Path, path: &Path) -> PathBuf {
574 if let Ok(rel) = path.strip_prefix(workspace) {
575 return rel.to_path_buf();
576 }
577 PathBuf::from(
578 path.file_name()
579 .map(|n| n.to_string_lossy().into_owned())
580 .unwrap_or_else(|| String::from("unknown")),
581 )
582 }
583
584 /// Used for tests / no-op runs. Builds an empty manager that always returns
585 /// `None`. Needed because the engine constructs an `LspManager` even when
586 /// the user has disabled LSP, so the field is always present.
587 impl LspManager {
588 #[must_use]
589 pub fn disabled() -> Self {
590 Self::new(
591 LspConfig {
592 enabled: false,
593 ..LspConfig::default()
594 },
595 PathBuf::new(),
596 )
597 }
598 }
599
600 #[cfg(test)]
601 pub(crate) mod tests {
602 use super::*;
603 use async_trait::async_trait;
604 use std::sync::atomic::{AtomicUsize, Ordering};
605
606 /// Fake transport: returns a fixed list of diagnostics. Used by
607 /// integration tests so we never spawn a real LSP server in CI.
608 pub(crate) struct FakeTransport {
609 items: Vec<Diagnostic>,
610 calls: AtomicUsize,
611 }
612
613 impl FakeTransport {
614 pub(crate) fn new(items: Vec<Diagnostic>) -> Self {
615 Self {
616 items,
617 calls: AtomicUsize::new(0),
618 }
619 }
620
621 pub(crate) fn call_count(&self) -> usize {
622 self.calls.load(Ordering::Relaxed)
623 }
624 }
625
626 #[async_trait]
627 impl LspTransport for FakeTransport {
628 async fn diagnostics_for(
629 &self,
630 _path: &Path,
631 _text: &str,
632 _wait: Duration,
633 ) -> anyhow::Result<Vec<Diagnostic>> {
634 self.calls.fetch_add(1, Ordering::Relaxed);
635 Ok(self.items.clone())
636 }
637
638 async fn shutdown(&self) {}
639 }
640
641 #[tokio::test]
642 async fn returns_none_when_disabled() {
643 let mgr = LspManager::new(
644 LspConfig {
645 enabled: false,
646 ..LspConfig::default()
647 },
648 PathBuf::from("/tmp"),
649 );
650 let dir = tempfile::tempdir().unwrap();
651 let path = dir.path().join("foo.rs");
652 tokio::fs::write(&path, b"fn main() {}").await.unwrap();
653 assert!(mgr.diagnostics_for(&path, 1).await.is_none());
654 }
655
656 #[tokio::test]
657 async fn returns_none_for_unknown_language() {
658 let dir = tempfile::tempdir().unwrap();
659 let mgr = LspManager::new(LspConfig::default(), dir.path().to_path_buf());
660 let path = dir.path().join("notes.txt");
661 tokio::fs::write(&path, b"hi").await.unwrap();
662 assert!(mgr.diagnostics_for(&path, 1).await.is_none());
663 }
664
665 #[tokio::test]
666 async fn forwards_errors_through_fake_transport() {
667 let dir = tempfile::tempdir().unwrap();
668 let mgr = LspManager::new(LspConfig::default(), dir.path().to_path_buf());
669 let path = dir.path().join("foo.rs");
670 tokio::fs::write(&path, b"let x: i32 = \"oops\";")
671 .await
672 .unwrap();
673
674 let fake = Arc::new(FakeTransport::new(vec![Diagnostic {
675 line: 1,
676 column: 14,
677 severity: Severity::Error,
678 message: "expected i32, found &str".to_string(),
679 }]));
680 mgr.install_test_transport(Language::Rust, fake.clone())
681 .await;
682
683 let block = mgr.diagnostics_for(&path, 1).await.expect("has block");
684 let rendered = block.render();
685 assert!(rendered.contains("ERROR [1:14] expected i32, found &str"));
686 assert!(rendered.contains("foo.rs"));
687 assert_eq!(fake.call_count(), 1);
688 }
689
690 #[tokio::test]
691 async fn drops_warnings_by_default() {
692 let dir = tempfile::tempdir().unwrap();
693 let mgr = LspManager::new(LspConfig::default(), dir.path().to_path_buf());
694 let path = dir.path().join("foo.rs");
695 tokio::fs::write(&path, b"fn main() {}").await.unwrap();
696
697 let fake = Arc::new(FakeTransport::new(vec![
698 Diagnostic {
699 line: 1,
700 column: 1,
701 severity: Severity::Warning,
702 message: "unused import".to_string(),
703 },
704 Diagnostic {
705 line: 2,
706 column: 1,
707 severity: Severity::Error,
708 message: "type error".to_string(),
709 },
710 ]));
711 mgr.install_test_transport(Language::Rust, fake).await;
712
713 let block = mgr.diagnostics_for(&path, 1).await.expect("has block");
714 assert_eq!(block.items.len(), 1);
715 assert_eq!(block.items[0].severity, Severity::Error);
716 }
717
718 #[tokio::test]
719 async fn keeps_warnings_when_opted_in() {
720 let dir = tempfile::tempdir().unwrap();
721 let mgr = LspManager::new(
722 LspConfig {
723 include_warnings: true,
724 ..LspConfig::default()
725 },
726 dir.path().to_path_buf(),
727 );
728 let path = dir.path().join("foo.rs");
729 tokio::fs::write(&path, b"fn main() {}").await.unwrap();
730
731 let fake = Arc::new(FakeTransport::new(vec![
732 Diagnostic {
733 line: 1,
734 column: 1,
735 severity: Severity::Warning,
736 message: "unused".to_string(),
737 },
738 Diagnostic {
739 line: 2,
740 column: 1,
741 severity: Severity::Error,
742 message: "broken".to_string(),
743 },
744 ]));
745 mgr.install_test_transport(Language::Rust, fake).await;
746
747 let block = mgr.diagnostics_for(&path, 1).await.expect("has block");
748 assert_eq!(block.items.len(), 2);
749 // Errors come first after sorting.
750 assert_eq!(block.items[0].severity, Severity::Error);
751 assert_eq!(block.items[1].severity, Severity::Warning);
752 }
753
754 #[tokio::test]
755 async fn truncates_to_max_per_file() {
756 let dir = tempfile::tempdir().unwrap();
757 let mgr = LspManager::new(
758 LspConfig {
759 max_diagnostics_per_file: 3,
760 ..LspConfig::default()
761 },
762 dir.path().to_path_buf(),
763 );
764 let path = dir.path().join("foo.rs");
765 tokio::fs::write(&path, b"fn main() {}").await.unwrap();
766
767 let fake = Arc::new(FakeTransport::new(
768 (0..10)
769 .map(|i| Diagnostic {
770 line: i + 1,
771 column: 1,
772 severity: Severity::Error,
773 message: format!("err {i}"),
774 })
775 .collect(),
776 ));
777 mgr.install_test_transport(Language::Rust, fake).await;
778
779 let block = mgr.diagnostics_for(&path, 1).await.expect("has block");
780 assert_eq!(block.items.len(), 3);
781 }
782
783 #[tokio::test]
784 async fn render_blocks_concatenates() {
785 let blocks = vec![
786 DiagnosticBlock {
787 file: PathBuf::from("a.rs"),
788 items: vec![Diagnostic {
789 line: 1,
790 column: 1,
791 severity: Severity::Error,
792 message: "err in a".to_string(),
793 }],
794 },
795 DiagnosticBlock {
796 file: PathBuf::from("b.rs"),
797 items: vec![Diagnostic {
798 line: 2,
799 column: 2,
800 severity: Severity::Error,
801 message: "err in b".to_string(),
802 }],
803 },
804 ];
805 let rendered = render_blocks(&blocks);
806 assert!(rendered.contains("file=\"a.rs\""));
807 assert!(rendered.contains("file=\"b.rs\""));
808 }
809
810 #[test]
811 fn relative_path_falls_back_to_filename_when_outside_workspace() {
812 let workspace = PathBuf::from("/foo/bar");
813 let path = PathBuf::from("/baz/qux.rs");
814 assert_eq!(
815 relative_to_workspace(&workspace, &path),
816 PathBuf::from("qux.rs")
817 );
818 }
819
820 #[test]
821 fn config_resolve_uses_overrides() {
822 let mut cfg = LspConfig::default();
823 cfg.servers.insert(
824 "rust".to_string(),
825 vec!["custom-rls".to_string(), "--lsp".to_string()],
826 );
827 let (cmd, args) = cfg.resolve_command(Language::Rust).unwrap();
828 assert_eq!(cmd, "custom-rls");
829 assert_eq!(args, vec!["--lsp".to_string()]);
830 }
831
832 #[test]
833 fn config_resolve_falls_back_to_registry() {
834 let cfg = LspConfig::default();
835 let (cmd, _) = cfg.resolve_command(Language::Rust).unwrap();
836 assert_eq!(cmd, "rust-analyzer");
837 }
838
839 // ── custom server extension tests ─────────────────────────────────────
840
841 #[test]
842 fn custom_for_extension_none_for_empty_config() {
843 let cfg = LspConfig::default();
844 assert!(cfg.custom_for_extension(&PathBuf::from("foo.rb")).is_none());
845 }
846
847 #[test]
848 fn custom_for_extension_finds_registered_extension() {
849 let mut cfg = LspConfig::default();
850 cfg.custom.insert(
851 "rb".to_string(),
852 CustomLspDef {
853 language_id: "ruby".to_string(),
854 command: "ruby-lsp".to_string(),
855 args: vec!["--stdio".to_string()],
856 },
857 );
858 let def = cfg
859 .custom_for_extension(&PathBuf::from("lib/hello.rb"))
860 .expect("should find rb");
861 assert_eq!(def.language_id, "ruby");
862 assert_eq!(def.command, "ruby-lsp");
863 }
864
865 #[test]
866 fn custom_for_extension_case_insensitive() {
867 let mut cfg = LspConfig::default();
868 cfg.custom.insert(
869 "cs".to_string(),
870 CustomLspDef {
871 language_id: "csharp".to_string(),
872 command: "csharp-ls".to_string(),
873 args: vec![],
874 },
875 );
876 assert!(cfg.custom_for_extension(&PathBuf::from("App.CS")).is_some());
877 assert!(cfg.custom_for_extension(&PathBuf::from("App.Cs")).is_some());
878 }
879
880 #[tokio::test]
881 async fn custom_fallback_only_for_other_language() {
882 // Even if [lsp.custom.go] is configured, .go files must still use
883 // the built-in gopls path — custom is a fallback, not an override.
884 let dir = tempfile::tempdir().unwrap();
885 let mut cfg = LspConfig::default();
886 cfg.custom.insert(
887 "go".to_string(),
888 CustomLspDef {
889 language_id: "go".to_string(),
890 command: "custom-gopls".to_string(),
891 args: vec![],
892 },
893 );
894 let mgr = LspManager::new(cfg, dir.path().to_path_buf());
895 let path = dir.path().join("main.go");
896 tokio::fs::write(&path, b"package main\n").await.unwrap();
897
898 // Inject a fake transport for the built-in Go path; we do NOT
899 // inject one for the custom path — so if it accidentally takes
900 // the custom route it will return None.
901 let fake = Arc::new(FakeTransport::new(vec![Diagnostic {
902 line: 1,
903 column: 1,
904 severity: Severity::Error,
905 message: "builtin-go-diag".to_string(),
906 }]));
907 mgr.install_test_transport(Language::Go, fake).await;
908
909 // No custom transport injected — if it hits custom, it returns None.
910 // If it hits built-in, it returns the fake diagnostic.
911 let block = mgr.diagnostics_for(&path, 1).await.expect("has block");
912 let rendered = block.render();
913 assert!(
914 rendered.contains("builtin-go-diag"),
915 "should use built-in Go transport, not custom override: {rendered}"
916 );
917 }
918
919 #[tokio::test]
920 async fn diagnostics_for_custom_returns_diagnostics() {
921 let dir = tempfile::tempdir().unwrap();
922 let mut cfg = LspConfig::default();
923 cfg.custom.insert(
924 "rb".to_string(),
925 CustomLspDef {
926 language_id: "ruby".to_string(),
927 command: "ruby-lsp".to_string(),
928 args: vec![],
929 },
930 );
931 let mgr = LspManager::new(cfg, dir.path().to_path_buf());
932 let path = dir.path().join("app.rb");
933 tokio::fs::write(&path, b"def foo; end\n").await.unwrap();
934
935 // Inject fake transport into the custom-transport map.
936 let fake = Arc::new(FakeTransport::new(vec![Diagnostic {
937 line: 1,
938 column: 5,
939 severity: Severity::Error,
940 message: "ruby type error".to_string(),
941 }]));
942 mgr.custom_transports
943 .lock()
944 .await
945 .insert("rb".to_string(), fake.clone());
946
947 let block = mgr.diagnostics_for(&path, 1).await.expect("has block");
948 let rendered = block.render();
949 assert!(rendered.contains("ruby type error"));
950 assert_eq!(fake.call_count(), 1);
951 }
952
953 #[tokio::test]
954 async fn custom_unregistered_extension_returns_none() {
955 let dir = tempfile::tempdir().unwrap();
956 let cfg = LspConfig::default();
957 let mgr = LspManager::new(cfg, dir.path().to_path_buf());
958 let path = dir.path().join("script.lua");
959 tokio::fs::write(&path, b"print('hi')\n").await.unwrap();
960
961 // No custom config for .lua and Lua is not built-in → should be None.
962 assert!(mgr.diagnostics_for(&path, 1).await.is_none());
963 }
964 }
965
965 lines RUST