1use std::collections::{HashMap, HashSet};
2use std::path::{Path, PathBuf};
3
4use crossbeam_channel::{unbounded, Receiver, RecvTimeoutError, Sender};
5use lsp_types::notification::{
6 DidChangeTextDocument, DidChangeWatchedFiles, DidCloseTextDocument, DidOpenTextDocument,
7};
8use lsp_types::{
9 DidChangeTextDocumentParams, DidChangeWatchedFilesParams, DidCloseTextDocumentParams,
10 DidOpenTextDocumentParams, FileChangeType, FileEvent, TextDocumentContentChangeEvent,
11 TextDocumentIdentifier, TextDocumentItem, VersionedTextDocumentIdentifier,
12};
13
14use crate::config::Config;
15use crate::lsp::child_registry::LspChildRegistry;
16use crate::lsp::client::{LspClient, LspEvent, ServerState};
17use crate::lsp::diagnostics::{
18 from_lsp_diagnostics, DiagnosticEntry, DiagnosticsStore, StoredDiagnostic,
19};
20use crate::lsp::document::DocumentStore;
21use crate::lsp::position::{uri_for_path, uri_to_path};
22use crate::lsp::pull_params::{
23 AftDocumentDiagnosticParams, AftDocumentDiagnosticRequest, AftWorkspaceDiagnosticParams,
24 AftWorkspaceDiagnosticRequest,
25};
26use crate::lsp::registry::{resolve_lsp_binary, servers_for_file, ServerDef, ServerKind};
27use crate::lsp::roots::{find_workspace_root, ServerKey};
28use crate::lsp::LspError;
29use crate::slog_error;
30
31const STDERR_REASON_BYTES: usize = 2 * 1024;
32
33#[derive(Debug, Clone)]
38pub enum ServerAttemptResult {
39 Ok { server_key: ServerKey },
41 NoRootMarker { looked_for: Vec<String> },
44 BinaryNotInstalled { binary: String },
47 SpawnFailed { binary: String, reason: String },
49}
50
51#[derive(Debug, Clone)]
53pub struct ServerAttempt {
54 pub server_id: String,
56 pub server_name: String,
58 pub result: ServerAttemptResult,
59}
60
61#[derive(Debug, Clone, Default)]
67pub struct EnsureServerOutcomes {
68 pub successful: Vec<ServerKey>,
70 pub attempts: Vec<ServerAttempt>,
73}
74
75impl EnsureServerOutcomes {
76 pub fn no_server_registered(&self) -> bool {
78 self.attempts.is_empty()
79 }
80
81 pub fn only_inapplicable_root_markers(&self) -> bool {
89 self.successful.is_empty()
90 && !self.attempts.is_empty()
91 && self
92 .attempts
93 .iter()
94 .all(|attempt| matches!(attempt.result, ServerAttemptResult::NoRootMarker { .. }))
95 }
96}
97
98#[derive(Debug, Clone, Default)]
108pub struct PostEditWaitOutcome {
109 pub diagnostics: Vec<StoredDiagnostic>,
113 pub pending_servers: Vec<ServerKey>,
117 pub exited_servers: Vec<ServerKey>,
121}
122
123#[derive(Debug, Clone, Copy, Default)]
125pub struct PreEditSnapshot {
126 pub epoch: u64,
127 pub document_version_at_capture: Option<i32>,
128}
129
130pub fn post_edit_entry_is_fresh(
131 entry: &DiagnosticEntry,
132 target_version: i32,
133 pre: PreEditSnapshot,
134) -> bool {
135 if entry.epoch <= pre.epoch {
136 return false;
137 }
138
139 match entry.version {
140 Some(version) => version >= target_version,
141 None => false,
146 }
147}
148
149impl PostEditWaitOutcome {
150 pub fn complete(&self) -> bool {
153 self.pending_servers.is_empty() && self.exited_servers.is_empty()
154 }
155}
156
157#[derive(Debug, Clone)]
159pub enum PullFileOutcome {
160 Full { diagnostic_count: usize },
162 Unchanged,
164 PartialNotSupported,
167 PullNotSupported,
170 RequestFailed { reason: String },
172}
173
174#[derive(Debug, Clone)]
176pub struct PullFileResult {
177 pub server_key: ServerKey,
178 pub outcome: PullFileOutcome,
179}
180
181#[derive(Debug, Clone)]
183pub struct PullWorkspaceResult {
184 pub server_key: ServerKey,
185 pub files_reported: Vec<PathBuf>,
189 pub complete: bool,
191 pub cancelled: bool,
193 pub supports_workspace: bool,
197}
198
199pub struct LspManager {
200 clients: HashMap<ServerKey, LspClient>,
202 server_binaries: HashMap<ServerKey, String>,
206 documents: HashMap<ServerKey, DocumentStore>,
208 diagnostics: DiagnosticsStore,
210 event_tx: Sender<LspEvent>,
212 event_rx: Receiver<LspEvent>,
213 binary_overrides: HashMap<ServerKind, PathBuf>,
215 extra_env: HashMap<String, String>,
219 failed_spawns: HashMap<ServerKey, ServerAttemptResult>,
234 watched_file_skip_logged: HashSet<ServerKey>,
237 child_registry: LspChildRegistry,
241}
242
243impl LspManager {
244 pub fn new() -> Self {
245 let (event_tx, event_rx) = unbounded();
246 Self {
247 clients: HashMap::new(),
248 server_binaries: HashMap::new(),
249 documents: HashMap::new(),
250 diagnostics: DiagnosticsStore::new(),
251 event_tx,
252 event_rx,
253 binary_overrides: HashMap::new(),
254 extra_env: HashMap::new(),
255 failed_spawns: HashMap::new(),
256 watched_file_skip_logged: HashSet::new(),
257 child_registry: LspChildRegistry::new(),
258 }
259 }
260
261 pub fn set_child_registry(&mut self, registry: LspChildRegistry) {
263 self.child_registry = registry;
264 }
265
266 pub fn set_extra_env(&mut self, key: &str, value: &str) {
270 self.extra_env.insert(key.to_string(), value.to_string());
271 }
272
273 pub fn server_count(&self) -> usize {
275 self.clients.len()
276 }
277
278 pub fn override_binary(&mut self, kind: ServerKind, binary_path: PathBuf) {
280 self.binary_overrides.insert(kind, binary_path);
281 }
282
283 pub fn ensure_server_for_file(&mut self, file_path: &Path, config: &Config) -> Vec<ServerKey> {
290 self.ensure_server_for_file_detailed(file_path, config)
291 .successful
292 }
293
294 pub fn ensure_server_for_file_detailed(
302 &mut self,
303 file_path: &Path,
304 config: &Config,
305 ) -> EnsureServerOutcomes {
306 let defs = servers_for_file(file_path, config);
307 let mut outcomes = EnsureServerOutcomes::default();
308
309 for def in defs {
310 let server_id = def.kind.id_str().to_string();
311 let server_name = def.name.to_string();
312
313 let Some(root) = find_workspace_root(file_path, &def.root_markers) else {
314 outcomes.attempts.push(ServerAttempt {
315 server_id,
316 server_name,
317 result: ServerAttemptResult::NoRootMarker {
318 looked_for: def.root_markers.iter().map(|s| s.to_string()).collect(),
319 },
320 });
321 continue;
322 };
323
324 let key = ServerKey {
325 kind: def.kind.clone(),
326 root,
327 };
328
329 if !self.clients.contains_key(&key) {
330 if let Some(cached) = self.failed_spawns.get(&key) {
337 outcomes.attempts.push(ServerAttempt {
338 server_id,
339 server_name,
340 result: cached.clone(),
341 });
342 continue;
343 }
344
345 match self.spawn_server(&def, &key.root, config) {
346 Ok(client) => {
347 self.clients.insert(key.clone(), client);
348 self.server_binaries.insert(key.clone(), def.binary.clone());
349 self.documents.entry(key.clone()).or_default();
350 }
351 Err(err) => {
352 slog_error!("failed to spawn {}: {}", def.name, err);
353 let result = classify_spawn_error(&def.binary, &err);
354 self.failed_spawns.insert(key.clone(), result.clone());
358 outcomes.attempts.push(ServerAttempt {
359 server_id,
360 server_name,
361 result,
362 });
363 continue;
364 }
365 }
366 }
367
368 outcomes.attempts.push(ServerAttempt {
369 server_id,
370 server_name,
371 result: ServerAttemptResult::Ok {
372 server_key: key.clone(),
373 },
374 });
375 outcomes.successful.push(key);
376 }
377
378 outcomes
379 }
380
381 pub fn ensure_server_for_file_default(&mut self, file_path: &Path) -> Vec<ServerKey> {
384 self.ensure_server_for_file(file_path, &Config::default())
385 }
386 pub fn ensure_file_open(
390 &mut self,
391 file_path: &Path,
392 config: &Config,
393 ) -> Result<Vec<ServerKey>, LspError> {
394 let canonical_path = canonicalize_for_lsp(file_path)?;
395 let server_keys = self.ensure_server_for_file(&canonical_path, config);
396 if server_keys.is_empty() {
397 return Ok(server_keys);
398 }
399
400 let uri = uri_for_path(&canonical_path)?;
401 let language_id = language_id_for_extension(
402 canonical_path
403 .extension()
404 .and_then(|ext| ext.to_str())
405 .unwrap_or_default(),
406 )
407 .to_string();
408
409 for key in &server_keys {
410 let already_open = self
411 .documents
412 .get(key)
413 .is_some_and(|store| store.is_open(&canonical_path));
414
415 if !already_open {
416 let content = std::fs::read_to_string(&canonical_path).map_err(LspError::Io)?;
417 if let Some(client) = self.clients.get_mut(key) {
418 client.send_notification::<DidOpenTextDocument>(DidOpenTextDocumentParams {
419 text_document: TextDocumentItem::new(
420 uri.clone(),
421 language_id.clone(),
422 0,
423 content,
424 ),
425 })?;
426 }
427 self.documents
428 .entry(key.clone())
429 .or_default()
430 .open(canonical_path.clone());
431 continue;
432 }
433
434 let drifted = self
444 .documents
445 .get(key)
446 .is_some_and(|store| store.is_stale_on_disk(&canonical_path));
447 if drifted {
448 let content = std::fs::read_to_string(&canonical_path).map_err(LspError::Io)?;
449 let next_version = self
450 .documents
451 .get(key)
452 .and_then(|store| store.version(&canonical_path))
453 .map(|v| v + 1)
454 .unwrap_or(1);
455 if let Some(client) = self.clients.get_mut(key) {
456 client.send_notification::<DidChangeTextDocument>(
457 DidChangeTextDocumentParams {
458 text_document: VersionedTextDocumentIdentifier::new(
459 uri.clone(),
460 next_version,
461 ),
462 content_changes: vec![TextDocumentContentChangeEvent {
463 range: None,
464 range_length: None,
465 text: content,
466 }],
467 },
468 )?;
469 }
470 if let Some(store) = self.documents.get_mut(key) {
471 store.bump_version(&canonical_path);
472 }
473 }
474 }
475
476 Ok(server_keys)
477 }
478
479 pub fn ensure_file_open_default(
480 &mut self,
481 file_path: &Path,
482 ) -> Result<Vec<ServerKey>, LspError> {
483 self.ensure_file_open(file_path, &Config::default())
484 }
485
486 pub fn notify_file_changed(
492 &mut self,
493 file_path: &Path,
494 content: &str,
495 config: &Config,
496 ) -> Result<(), LspError> {
497 self.notify_file_changed_versioned(file_path, content, config)
498 .map(|_| ())
499 }
500
501 pub fn notify_file_changed_versioned(
512 &mut self,
513 file_path: &Path,
514 content: &str,
515 config: &Config,
516 ) -> Result<Vec<(ServerKey, i32)>, LspError> {
517 let canonical_path = canonicalize_for_lsp(file_path)?;
518 let server_keys = self.ensure_server_for_file(&canonical_path, config);
519 if server_keys.is_empty() {
520 return Ok(Vec::new());
521 }
522
523 let uri = uri_for_path(&canonical_path)?;
524 let language_id = language_id_for_extension(
525 canonical_path
526 .extension()
527 .and_then(|ext| ext.to_str())
528 .unwrap_or_default(),
529 )
530 .to_string();
531
532 let mut versions: Vec<(ServerKey, i32)> = Vec::with_capacity(server_keys.len());
533
534 for key in server_keys {
535 let current_version = self
536 .documents
537 .get(&key)
538 .and_then(|store| store.version(&canonical_path));
539
540 if let Some(version) = current_version {
541 let next_version = version + 1;
542 if let Some(client) = self.clients.get_mut(&key) {
543 client.send_notification::<DidChangeTextDocument>(
544 DidChangeTextDocumentParams {
545 text_document: VersionedTextDocumentIdentifier::new(
546 uri.clone(),
547 next_version,
548 ),
549 content_changes: vec![TextDocumentContentChangeEvent {
550 range: None,
551 range_length: None,
552 text: content.to_string(),
553 }],
554 },
555 )?;
556 }
557 if let Some(store) = self.documents.get_mut(&key) {
558 store.bump_version(&canonical_path);
559 }
560 versions.push((key, next_version));
561 continue;
562 }
563
564 if let Some(client) = self.clients.get_mut(&key) {
565 client.send_notification::<DidOpenTextDocument>(DidOpenTextDocumentParams {
566 text_document: TextDocumentItem::new(
567 uri.clone(),
568 language_id.clone(),
569 0,
570 content.to_string(),
571 ),
572 })?;
573 }
574 self.documents
575 .entry(key.clone())
576 .or_default()
577 .open(canonical_path.clone());
578 versions.push((key, 0));
581 }
582
583 Ok(versions)
584 }
585
586 pub fn notify_file_changed_default(
587 &mut self,
588 file_path: &Path,
589 content: &str,
590 ) -> Result<(), LspError> {
591 self.notify_file_changed(file_path, content, &Config::default())
592 }
593
594 pub fn notify_files_watched_changed(
600 &mut self,
601 paths: &[(PathBuf, FileChangeType)],
602 _config: &Config,
603 ) -> Result<(), LspError> {
604 if paths.is_empty() {
605 return Ok(());
606 }
607
608 let mut canonical_events = Vec::with_capacity(paths.len());
609 for (path, typ) in paths {
610 let canonical_path = resolve_for_lsp_uri(path);
611 canonical_events.push((canonical_path, *typ));
612 }
613
614 let keys: Vec<ServerKey> = self.clients.keys().cloned().collect();
615 for key in keys {
616 let mut changes = Vec::new();
617 for (path, typ) in &canonical_events {
618 if !path.starts_with(&key.root) {
619 continue;
620 }
621 changes.push(FileEvent::new(uri_for_path(path)?, *typ));
622 }
623
624 if changes.is_empty() {
625 continue;
626 }
627
628 if let Some(client) = self.clients.get_mut(&key) {
629 let supports_static_watched_files = client.supports_watched_files();
635 let has_dynamic_registration = client.has_watched_file_registration();
636 if !(supports_static_watched_files || has_dynamic_registration) {
637 if self.watched_file_skip_logged.insert(key.clone()) {
638 log::debug!(
639 "skipping didChangeWatchedFiles for {:?} (not supported or registered)",
640 key
641 );
642 }
643 continue;
644 }
645 client.send_notification::<DidChangeWatchedFiles>(DidChangeWatchedFilesParams {
646 changes,
647 })?;
648 }
649 }
650
651 Ok(())
652 }
653
654 pub fn notify_file_closed(&mut self, file_path: &Path) -> Result<(), LspError> {
656 let canonical_path = canonicalize_for_lsp(file_path)?;
657 let uri = uri_for_path(&canonical_path)?;
658 let keys: Vec<ServerKey> = self.documents.keys().cloned().collect();
659
660 for key in keys {
661 let was_open = self
662 .documents
663 .get(&key)
664 .map(|store| store.is_open(&canonical_path))
665 .unwrap_or(false);
666 if !was_open {
667 continue;
668 }
669
670 if let Some(client) = self.clients.get_mut(&key) {
671 client.send_notification::<DidCloseTextDocument>(DidCloseTextDocumentParams {
672 text_document: TextDocumentIdentifier::new(uri.clone()),
673 })?;
674 }
675
676 if let Some(store) = self.documents.get_mut(&key) {
677 store.close(&canonical_path);
678 }
679 self.diagnostics
680 .clear_for_server_file(&key, &canonical_path);
681 }
682
683 Ok(())
684 }
685
686 pub fn client_for_file(&self, file_path: &Path, config: &Config) -> Option<&LspClient> {
688 let key = self.server_key_for_file(file_path, config)?;
689 self.clients.get(&key)
690 }
691
692 pub fn client_for_file_default(&self, file_path: &Path) -> Option<&LspClient> {
693 self.client_for_file(file_path, &Config::default())
694 }
695
696 pub fn client_for_file_mut(
698 &mut self,
699 file_path: &Path,
700 config: &Config,
701 ) -> Option<&mut LspClient> {
702 let key = self.server_key_for_file(file_path, config)?;
703 self.clients.get_mut(&key)
704 }
705
706 pub fn client_for_file_mut_default(&mut self, file_path: &Path) -> Option<&mut LspClient> {
707 self.client_for_file_mut(file_path, &Config::default())
708 }
709
710 pub fn active_client_count(&self) -> usize {
712 self.clients.len()
713 }
714
715 pub fn drain_events(&mut self) -> Vec<LspEvent> {
717 let mut events = Vec::new();
718 while let Ok(event) = self.event_rx.try_recv() {
719 self.handle_event(&event);
720 events.push(event);
721 }
722 events
723 }
724
725 pub fn wait_for_diagnostics(
727 &mut self,
728 file_path: &Path,
729 config: &Config,
730 timeout: std::time::Duration,
731 ) -> Vec<StoredDiagnostic> {
732 let deadline = std::time::Instant::now() + timeout;
733 self.wait_for_file_diagnostics(file_path, config, deadline)
734 }
735
736 pub fn wait_for_diagnostics_default(
737 &mut self,
738 file_path: &Path,
739 timeout: std::time::Duration,
740 ) -> Vec<StoredDiagnostic> {
741 self.wait_for_diagnostics(file_path, &Config::default(), timeout)
742 }
743
744 #[doc(hidden)]
749 pub fn diagnostics_store_for_test(&self) -> &DiagnosticsStore {
750 &self.diagnostics
751 }
752
753 #[doc(hidden)]
754 #[cfg(test)]
755 pub fn diagnostics_store_mut_for_test(&mut self) -> &mut DiagnosticsStore {
756 &mut self.diagnostics
757 }
758
759 pub fn warm_error_warning_counts(&self) -> (usize, usize) {
763 self.diagnostics.error_warning_counts()
764 }
765
766 pub fn snapshot_diagnostic_epochs(&self, file_path: &Path) -> HashMap<ServerKey, u64> {
771 let lookup_path = normalize_lookup_path(file_path);
772 self.diagnostics
773 .entries_for_file(&lookup_path)
774 .into_iter()
775 .map(|(key, entry)| (key.clone(), entry.epoch))
776 .collect()
777 }
778
779 pub fn snapshot_pre_edit_state(&self, file_path: &Path) -> HashMap<ServerKey, PreEditSnapshot> {
782 let lookup_path = normalize_lookup_path(file_path);
783 let mut snapshots: HashMap<ServerKey, PreEditSnapshot> = self
784 .diagnostics
785 .entries_for_file(&lookup_path)
786 .into_iter()
787 .map(|(key, entry)| {
788 (
789 key.clone(),
790 PreEditSnapshot {
791 epoch: entry.epoch,
792 document_version_at_capture: None,
793 },
794 )
795 })
796 .collect();
797
798 for (key, store) in &self.documents {
799 if let Some(version) = store.version(&lookup_path) {
800 snapshots
801 .entry(key.clone())
802 .or_default()
803 .document_version_at_capture = Some(version);
804 }
805 }
806
807 snapshots
808 }
809
810 pub fn diagnostic_entry_is_fresh_for_document(
818 &self,
819 file_path: &Path,
820 server_key: &ServerKey,
821 pre: PreEditSnapshot,
822 ) -> bool {
823 let lookup_path = normalize_lookup_path(file_path);
824 let Some(entry) = self
825 .diagnostics
826 .entries_for_file(&lookup_path)
827 .into_iter()
828 .find_map(|(key, entry)| if key == server_key { Some(entry) } else { None })
829 else {
830 return false;
831 };
832
833 let target_version = self
834 .documents
835 .get(server_key)
836 .and_then(|store| store.version(&lookup_path))
837 .or(pre.document_version_at_capture)
838 .unwrap_or(0);
839
840 matches!(entry.version, Some(version) if version >= target_version)
841 }
842
843 pub fn wait_for_post_edit_diagnostics(
866 &mut self,
867 file_path: &Path,
868 _config: &Config,
872 expected_versions: &[(ServerKey, i32)],
873 pre_snapshot: &HashMap<ServerKey, PreEditSnapshot>,
874 timeout: std::time::Duration,
875 ) -> PostEditWaitOutcome {
876 let lookup_path = normalize_lookup_path(file_path);
877 let deadline = std::time::Instant::now() + timeout;
878
879 let _ = self.drain_events_for_file(&lookup_path);
884
885 let mut fresh: HashMap<ServerKey, Vec<StoredDiagnostic>> = HashMap::new();
886 let mut exited: Vec<ServerKey> = Vec::new();
887
888 loop {
889 for (key, target_version) in expected_versions {
897 if fresh.contains_key(key) || exited.contains(key) {
898 continue;
899 }
900 if !self.clients.contains_key(key) {
901 exited.push(key.clone());
902 continue;
903 }
904 if let Some(entry) = self
905 .diagnostics
906 .entries_for_file(&lookup_path)
907 .into_iter()
908 .find_map(|(k, e)| if k == key { Some(e) } else { None })
909 {
910 let pre = pre_snapshot.get(key).copied().unwrap_or_default();
911 let is_fresh = post_edit_entry_is_fresh(entry, *target_version, pre);
912 if is_fresh {
913 fresh.insert(key.clone(), entry.diagnostics.clone());
914 }
915 }
916 }
917
918 if fresh.len() + exited.len() == expected_versions.len() {
920 break;
921 }
922
923 let now = std::time::Instant::now();
924 if now >= deadline {
925 break;
926 }
927
928 let timeout = deadline.saturating_duration_since(now);
929 match self.event_rx.recv_timeout(timeout) {
930 Ok(event) => {
931 self.handle_event(&event);
932 }
933 Err(RecvTimeoutError::Timeout) | Err(RecvTimeoutError::Disconnected) => break,
934 }
935 }
936
937 let pending: Vec<ServerKey> = expected_versions
939 .iter()
940 .filter(|(k, _)| !fresh.contains_key(k) && !exited.contains(k))
941 .map(|(k, _)| k.clone())
942 .collect();
943
944 let mut diagnostics: Vec<StoredDiagnostic> = fresh
947 .into_iter()
948 .flat_map(|(_, diags)| diags.into_iter())
949 .collect();
950 diagnostics.sort_by(|a, b| {
951 a.file
952 .cmp(&b.file)
953 .then(a.line.cmp(&b.line))
954 .then(a.column.cmp(&b.column))
955 .then(a.message.cmp(&b.message))
956 });
957
958 PostEditWaitOutcome {
959 diagnostics,
960 pending_servers: pending,
961 exited_servers: exited,
962 }
963 }
964
965 pub fn wait_for_file_diagnostics(
971 &mut self,
972 file_path: &Path,
973 config: &Config,
974 deadline: std::time::Instant,
975 ) -> Vec<StoredDiagnostic> {
976 let lookup_path = normalize_lookup_path(file_path);
977
978 if self.server_key_for_file(&lookup_path, config).is_none() {
979 return Vec::new();
980 }
981
982 loop {
983 if self.drain_events_for_file(&lookup_path) {
984 break;
985 }
986
987 let now = std::time::Instant::now();
988 if now >= deadline {
989 break;
990 }
991
992 let timeout = deadline.saturating_duration_since(now);
993 match self.event_rx.recv_timeout(timeout) {
994 Ok(event) => {
995 if matches!(
996 self.handle_event(&event),
997 Some(ref published_file) if published_file.as_path() == lookup_path.as_path()
998 ) {
999 break;
1000 }
1001 }
1002 Err(RecvTimeoutError::Timeout) | Err(RecvTimeoutError::Disconnected) => break,
1003 }
1004 }
1005
1006 self.get_diagnostics_for_file(&lookup_path)
1007 .into_iter()
1008 .cloned()
1009 .collect()
1010 }
1011
1012 pub const PULL_FILE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
1018
1019 pub fn pull_file_timeout() -> std::time::Duration {
1021 Self::PULL_FILE_TIMEOUT
1022 }
1023
1024 const PULL_WORKSPACE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
1028
1029 pub fn pull_file_diagnostics(
1040 &mut self,
1041 file_path: &Path,
1042 config: &Config,
1043 ) -> Result<Vec<PullFileResult>, LspError> {
1044 let canonical_path = canonicalize_for_lsp(file_path)?;
1045 self.ensure_file_open(&canonical_path, config)?;
1048
1049 let server_keys = self.ensure_server_for_file(&canonical_path, config);
1050 if server_keys.is_empty() {
1051 return Ok(Vec::new());
1052 }
1053
1054 let uri = uri_for_path(&canonical_path)?;
1055 let mut results = Vec::with_capacity(server_keys.len());
1056
1057 for key in server_keys {
1058 let supports_pull = self
1059 .clients
1060 .get(&key)
1061 .and_then(|c| c.diagnostic_capabilities())
1062 .is_some_and(|caps| caps.pull_diagnostics);
1063
1064 if !supports_pull {
1065 results.push(PullFileResult {
1066 server_key: key.clone(),
1067 outcome: PullFileOutcome::PullNotSupported,
1068 });
1069 continue;
1070 }
1071
1072 let previous_result_id = self
1074 .diagnostics
1075 .entries_for_file(&canonical_path)
1076 .into_iter()
1077 .find(|(k, _)| **k == key)
1078 .and_then(|(_, entry)| entry.result_id.clone());
1079
1080 let identifier = self
1081 .clients
1082 .get(&key)
1083 .and_then(|c| c.diagnostic_capabilities())
1084 .and_then(|caps| caps.identifier.clone());
1085
1086 let params = AftDocumentDiagnosticParams {
1087 text_document: lsp_types::TextDocumentIdentifier { uri: uri.clone() },
1088 identifier,
1089 previous_result_id,
1090 work_done_progress_params: Default::default(),
1091 partial_result_params: Default::default(),
1092 };
1093
1094 let outcome = match self.send_pull_request(&key, params) {
1095 Ok(report) => self.ingest_document_report(&key, &canonical_path, report),
1096 Err(err) => {
1097 if let Some(result) = self.cache_post_initialize_exit(&key, &err) {
1098 PullFileOutcome::RequestFailed {
1099 reason: server_attempt_result_reason(&result),
1100 }
1101 } else if recoverable_pull_rejection(&err)
1102 && self.clients.get(&key).is_some_and(|client| {
1103 matches!(
1104 client.state(),
1105 ServerState::Ready | ServerState::Initializing
1106 )
1107 })
1108 {
1109 PullFileOutcome::RequestFailed {
1110 reason: format!("pull_rejected_push_fallback: {err}"),
1111 }
1112 } else {
1113 PullFileOutcome::RequestFailed {
1114 reason: err.to_string(),
1115 }
1116 }
1117 }
1118 };
1119
1120 results.push(PullFileResult {
1121 server_key: key,
1122 outcome,
1123 });
1124 }
1125
1126 Ok(results)
1127 }
1128
1129 pub fn pull_workspace_diagnostics(
1134 &mut self,
1135 server_key: &ServerKey,
1136 timeout: Option<std::time::Duration>,
1137 ) -> Result<PullWorkspaceResult, LspError> {
1138 let timeout = timeout.unwrap_or(Self::PULL_WORKSPACE_TIMEOUT);
1139
1140 let supports_workspace = self
1141 .clients
1142 .get(server_key)
1143 .and_then(|c| c.diagnostic_capabilities())
1144 .is_some_and(|caps| caps.workspace_diagnostics);
1145
1146 if !supports_workspace {
1147 return Ok(PullWorkspaceResult {
1148 server_key: server_key.clone(),
1149 files_reported: Vec::new(),
1150 complete: false,
1151 cancelled: false,
1152 supports_workspace: false,
1153 });
1154 }
1155
1156 let identifier = self
1157 .clients
1158 .get(server_key)
1159 .and_then(|c| c.diagnostic_capabilities())
1160 .and_then(|caps| caps.identifier.clone());
1161
1162 let params = AftWorkspaceDiagnosticParams {
1163 identifier,
1164 previous_result_ids: Vec::new(),
1165 work_done_progress_params: Default::default(),
1166 partial_result_params: Default::default(),
1167 };
1168
1169 let result = match self
1170 .clients
1171 .get_mut(server_key)
1172 .ok_or_else(|| LspError::ServerNotReady("server not found".into()))?
1173 .send_request_with_timeout::<AftWorkspaceDiagnosticRequest>(params, timeout)
1174 {
1175 Ok(result) => result,
1176 Err(LspError::Timeout(_)) => {
1177 return Ok(PullWorkspaceResult {
1178 server_key: server_key.clone(),
1179 files_reported: Vec::new(),
1180 complete: false,
1181 cancelled: true,
1182 supports_workspace: true,
1183 });
1184 }
1185 Err(err) => {
1186 if let Some(result) = self.cache_post_initialize_exit(server_key, &err) {
1187 return Err(LspError::ServerNotReady(server_attempt_result_reason(
1188 &result,
1189 )));
1190 }
1191 return Err(err);
1192 }
1193 };
1194
1195 let (items, complete) = match result {
1199 lsp_types::WorkspaceDiagnosticReportResult::Report(report) => (report.items, true),
1200 lsp_types::WorkspaceDiagnosticReportResult::Partial(partial) => (partial.items, false),
1201 };
1202
1203 let mut files_reported = Vec::with_capacity(items.len());
1205 for item in items {
1206 match item {
1207 lsp_types::WorkspaceDocumentDiagnosticReport::Full(full) => {
1208 if let Some(file) = uri_to_path(&full.uri) {
1209 let stored = from_lsp_diagnostics(
1210 file.clone(),
1211 full.full_document_diagnostic_report.items.clone(),
1212 );
1213 self.diagnostics.publish_with_result_id(
1214 server_key.clone(),
1215 file.clone(),
1216 stored,
1217 full.full_document_diagnostic_report.result_id.clone(),
1218 );
1219 files_reported.push(file);
1220 }
1221 }
1222 lsp_types::WorkspaceDocumentDiagnosticReport::Unchanged(_unchanged) => {
1223 }
1226 }
1227 }
1228
1229 Ok(PullWorkspaceResult {
1230 server_key: server_key.clone(),
1231 files_reported,
1232 complete,
1233 cancelled: false,
1234 supports_workspace: true,
1235 })
1236 }
1237
1238 fn cache_post_initialize_exit(
1239 &mut self,
1240 key: &ServerKey,
1241 err: &LspError,
1242 ) -> Option<ServerAttemptResult> {
1243 let binary = self
1244 .server_binaries
1245 .get(key)
1246 .cloned()
1247 .unwrap_or_else(|| key.kind.id_str().to_string());
1248 let (status, stderr_tail) = {
1249 let client = self.clients.get_mut(key)?;
1250 let mut status = client.child_exit_status();
1251 for _ in 0..10 {
1252 if status.is_some() {
1253 break;
1254 }
1255 std::thread::sleep(std::time::Duration::from_millis(10));
1256 status = client.child_exit_status();
1257 }
1258 let status = status?;
1259 wait_for_stderr_tail(client);
1260 (status, client.stderr_tail())
1261 };
1262 let reason = format_post_initialize_exit_reason(&binary, status, &stderr_tail, err);
1263 let result = ServerAttemptResult::SpawnFailed { binary, reason };
1264 self.clients.remove(key);
1265 self.server_binaries.remove(key);
1266 self.documents.remove(key);
1267 self.diagnostics.clear_for_server(key);
1268 self.failed_spawns.insert(key.clone(), result.clone());
1269 Some(result)
1270 }
1271
1272 fn send_pull_request(
1274 &mut self,
1275 key: &ServerKey,
1276 params: AftDocumentDiagnosticParams,
1277 ) -> Result<lsp_types::DocumentDiagnosticReportResult, LspError> {
1278 let client = self
1279 .clients
1280 .get_mut(key)
1281 .ok_or_else(|| LspError::ServerNotReady("server not found".into()))?;
1282 client.send_request::<AftDocumentDiagnosticRequest>(params)
1283 }
1284
1285 fn ingest_document_report(
1288 &mut self,
1289 key: &ServerKey,
1290 canonical_path: &Path,
1291 result: lsp_types::DocumentDiagnosticReportResult,
1292 ) -> PullFileOutcome {
1293 let report = match result {
1294 lsp_types::DocumentDiagnosticReportResult::Report(report) => report,
1295 lsp_types::DocumentDiagnosticReportResult::Partial(_) => {
1296 return PullFileOutcome::PartialNotSupported;
1300 }
1301 };
1302
1303 match report {
1304 lsp_types::DocumentDiagnosticReport::Full(full) => {
1305 let result_id = full.full_document_diagnostic_report.result_id.clone();
1306 let stored = from_lsp_diagnostics(
1307 canonical_path.to_path_buf(),
1308 full.full_document_diagnostic_report.items.clone(),
1309 );
1310 let count = stored.len();
1311 self.diagnostics.publish_with_result_id(
1312 key.clone(),
1313 canonical_path.to_path_buf(),
1314 stored,
1315 result_id,
1316 );
1317 PullFileOutcome::Full {
1318 diagnostic_count: count,
1319 }
1320 }
1321 lsp_types::DocumentDiagnosticReport::Unchanged(_unchanged) => {
1322 if self
1326 .diagnostics
1327 .has_report_for_server_file(key, canonical_path)
1328 {
1329 PullFileOutcome::Unchanged
1330 } else {
1331 PullFileOutcome::RequestFailed {
1332 reason: "no_cache_for_unchanged".to_string(),
1333 }
1334 }
1335 }
1336 }
1337 }
1338
1339 pub fn shutdown_all(&mut self) {
1341 for (key, mut client) in self.clients.drain() {
1342 if let Err(err) = client.shutdown() {
1343 slog_error!("error shutting down {:?}: {}", key, err);
1344 }
1345 }
1346 self.server_binaries.clear();
1347 self.documents.clear();
1348 self.diagnostics = DiagnosticsStore::new();
1349 }
1350
1351 pub fn has_active_servers(&self) -> bool {
1353 self.clients
1354 .values()
1355 .any(|client| client.state() == ServerState::Ready)
1356 }
1357
1358 pub fn active_server_keys(&self) -> Vec<ServerKey> {
1361 self.clients.keys().cloned().collect()
1362 }
1363
1364 pub fn get_diagnostics_for_file(&self, file: &Path) -> Vec<&StoredDiagnostic> {
1365 let normalized = normalize_lookup_path(file);
1366 self.diagnostics.for_file(&normalized)
1367 }
1368
1369 pub fn clear_diagnostics_for_file(&mut self, file: &Path) -> bool {
1383 let mut removed = self.diagnostics.clear_for_file(file);
1384
1385 let normalized = normalize_lookup_path(file);
1386 if normalized != file {
1387 removed |= self.diagnostics.clear_for_file(&normalized);
1388 }
1389
1390 if let (Some(parent), Some(name)) = (file.parent(), file.file_name()) {
1393 if let Ok(canonical_parent) = std::fs::canonicalize(parent) {
1394 let reconstructed = canonical_parent.join(name);
1395 if reconstructed != file && reconstructed != normalized {
1396 removed |= self.diagnostics.clear_for_file(&reconstructed);
1397 }
1398 }
1399 }
1400
1401 removed
1402 }
1403
1404 pub fn get_diagnostics_for_directory(&self, dir: &Path) -> Vec<&StoredDiagnostic> {
1405 let normalized = normalize_lookup_path(dir);
1406 self.diagnostics.for_directory(&normalized)
1407 }
1408
1409 pub fn get_all_diagnostics(&self) -> Vec<&StoredDiagnostic> {
1410 self.diagnostics.all()
1411 }
1412
1413 pub fn has_any_diagnostic_reports(&self) -> bool {
1418 !self.diagnostics.is_empty()
1419 }
1420
1421 pub fn has_diagnostic_report_for_file(&self, file: &Path) -> bool {
1424 let normalized = normalize_lookup_path(file);
1425 self.diagnostics.has_any_report_for_file(&normalized)
1426 }
1427
1428 pub fn has_diagnostic_report_for_server_file(&self, server: &ServerKey, file: &Path) -> bool {
1431 let normalized = normalize_lookup_path(file);
1432 self.diagnostics
1433 .has_report_for_server_file(server, &normalized)
1434 }
1435
1436 fn drain_events_for_file(&mut self, file_path: &Path) -> bool {
1437 let mut saw_file_diagnostics = false;
1438 while let Ok(event) = self.event_rx.try_recv() {
1439 if matches!(
1440 self.handle_event(&event),
1441 Some(ref published_file) if published_file.as_path() == file_path
1442 ) {
1443 saw_file_diagnostics = true;
1444 }
1445 }
1446 saw_file_diagnostics
1447 }
1448
1449 fn handle_event(&mut self, event: &LspEvent) -> Option<PathBuf> {
1450 match event {
1451 LspEvent::Notification {
1452 server_kind,
1453 root,
1454 method,
1455 params: Some(params),
1456 } if method == "textDocument/publishDiagnostics" => {
1457 self.handle_publish_diagnostics(server_kind.clone(), root.clone(), params)
1458 }
1459 LspEvent::ServerExited { server_kind, root } => {
1460 let key = ServerKey {
1461 kind: server_kind.clone(),
1462 root: root.clone(),
1463 };
1464 self.clients.remove(&key);
1465 self.server_binaries.remove(&key);
1466 self.documents.remove(&key);
1467 self.diagnostics.clear_for_server(&key);
1468 None
1469 }
1470 _ => None,
1471 }
1472 }
1473
1474 fn handle_publish_diagnostics(
1475 &mut self,
1476 server: ServerKind,
1477 root: PathBuf,
1478 params: &serde_json::Value,
1479 ) -> Option<PathBuf> {
1480 if let Ok(publish_params) =
1481 serde_json::from_value::<lsp_types::PublishDiagnosticsParams>(params.clone())
1482 {
1483 let file = uri_to_path(&publish_params.uri)?;
1484 let stored = from_lsp_diagnostics(file.clone(), publish_params.diagnostics);
1485 let key = ServerKey { kind: server, root };
1491 self.diagnostics
1492 .publish_full(key, file.clone(), stored, None, publish_params.version);
1493 return Some(file);
1494 }
1495 None
1496 }
1497
1498 fn spawn_server(
1499 &self,
1500 def: &ServerDef,
1501 root: &Path,
1502 config: &Config,
1503 ) -> Result<LspClient, LspError> {
1504 let binary = self.resolve_binary(def, config)?;
1505
1506 let mut merged_env = def.env.clone();
1510 for (key, value) in &self.extra_env {
1511 merged_env.insert(key.clone(), value.clone());
1512 }
1513
1514 let mut client = LspClient::spawn(
1515 def.kind.clone(),
1516 root.to_path_buf(),
1517 &binary,
1518 &def.args,
1519 &merged_env,
1520 self.event_tx.clone(),
1521 self.child_registry.clone(),
1522 )?;
1523 if let Err(err) = client.initialize(root, def.initialization_options.clone()) {
1524 wait_for_stderr_tail(&mut client);
1525 let stderr_tail = client.stderr_tail();
1526 let reason = if client.child_exited() || !stderr_tail.is_empty() {
1527 format_initialize_failure_reason(&def.binary, &stderr_tail, &err)
1528 } else {
1529 format!("server failed during initialize: {err}")
1530 };
1531 return Err(LspError::ServerNotReady(reason));
1532 }
1533 Ok(client)
1534 }
1535
1536 fn resolve_binary(&self, def: &ServerDef, config: &Config) -> Result<PathBuf, LspError> {
1537 if let Some(path) = self.binary_overrides.get(&def.kind) {
1538 if path.exists() {
1539 return Ok(path.clone());
1540 }
1541 return Err(LspError::NotFound(format!(
1542 "override binary for {:?} not found: {}",
1543 def.kind,
1544 path.display()
1545 )));
1546 }
1547
1548 if let Some(path) = env_binary_override(&def.kind) {
1549 if path.exists() {
1550 return Ok(path);
1551 }
1552 return Err(LspError::NotFound(format!(
1553 "environment override binary for {:?} not found: {}",
1554 def.kind,
1555 path.display()
1556 )));
1557 }
1558
1559 resolve_lsp_binary(
1564 &def.binary,
1565 config.project_root.as_deref(),
1566 &config.lsp_paths_extra,
1567 )
1568 .ok_or_else(|| {
1569 LspError::NotFound(format!(
1570 "language server binary '{}' not found in node_modules/.bin, lsp_paths_extra, or PATH",
1571 def.binary
1572 ))
1573 })
1574 }
1575
1576 fn server_key_for_file(&self, file_path: &Path, config: &Config) -> Option<ServerKey> {
1577 for def in servers_for_file(file_path, config) {
1578 let root = find_workspace_root(file_path, &def.root_markers)?;
1579 let key = ServerKey {
1580 kind: def.kind.clone(),
1581 root,
1582 };
1583 if self.clients.contains_key(&key) {
1584 return Some(key);
1585 }
1586 }
1587 None
1588 }
1589}
1590
1591impl Default for LspManager {
1592 fn default() -> Self {
1593 Self::new()
1594 }
1595}
1596
1597fn wait_for_stderr_tail(client: &mut LspClient) {
1598 for _ in 0..10 {
1599 if !client.stderr_tail().is_empty() {
1600 break;
1601 }
1602 std::thread::sleep(std::time::Duration::from_millis(10));
1603 }
1604}
1605
1606fn recoverable_pull_rejection(err: &LspError) -> bool {
1607 matches!(
1608 err,
1609 LspError::ServerError {
1610 code: -32601 | -32602,
1611 ..
1612 }
1613 )
1614}
1615
1616fn server_attempt_result_reason(result: &ServerAttemptResult) -> String {
1617 match result {
1618 ServerAttemptResult::SpawnFailed { binary, reason } => {
1619 format!("spawn_failed: {binary} ({reason})")
1620 }
1621 ServerAttemptResult::BinaryNotInstalled { binary } => {
1622 format!("binary_not_installed: {binary}")
1623 }
1624 ServerAttemptResult::NoRootMarker { looked_for } => {
1625 format!("no_root_marker (looked for: {})", looked_for.join(", "))
1626 }
1627 ServerAttemptResult::Ok { .. } => "ok".to_string(),
1628 }
1629}
1630
1631fn format_stderr_tail_for_reason(stderr_tail: &str) -> String {
1632 truncate_stderr_tail_for_reason(stderr_tail)
1633 .lines()
1634 .map(|line| format!(" {line}"))
1635 .collect::<Vec<_>>()
1636 .join("\n")
1637}
1638
1639fn truncate_stderr_tail_for_reason(stderr_tail: &str) -> String {
1640 if stderr_tail.len() <= STDERR_REASON_BYTES {
1641 return stderr_tail.to_string();
1642 }
1643
1644 let ellipsis = "...";
1645 let target_len = STDERR_REASON_BYTES.saturating_sub(ellipsis.len());
1646 let mut start = stderr_tail.len() - target_len;
1647 while start < stderr_tail.len() && !stderr_tail.is_char_boundary(start) {
1648 start += 1;
1649 }
1650 format!("{ellipsis}{}", &stderr_tail[start..])
1651}
1652
1653fn format_initialize_failure_reason(binary: &str, stderr_tail: &str, err: &LspError) -> String {
1654 let mut reason = format!("server crashed during initialize: {err}");
1655 if !stderr_tail.is_empty() {
1656 reason.push_str("; stderr (last 64 lines):\n");
1657 reason.push_str(&format_stderr_tail_for_reason(stderr_tail));
1658 reason.push_str("\n\n");
1659 reason.push_str(&failure_hint(binary, stderr_tail));
1660 }
1661 reason
1662}
1663
1664fn format_post_initialize_exit_reason(
1665 binary: &str,
1666 status: std::process::ExitStatus,
1667 stderr_tail: &str,
1668 err: &LspError,
1669) -> String {
1670 let code = status
1671 .code()
1672 .map(|c| c.to_string())
1673 .unwrap_or_else(|| "signal/unknown".to_string());
1674 let mut reason = format!("server exited after initialize (code {code}): {err}");
1675 if !stderr_tail.is_empty() {
1676 reason.push_str("; stderr (last 64 lines):\n");
1677 reason.push_str(&format_stderr_tail_for_reason(stderr_tail));
1678 reason.push_str("\n\n");
1679 reason.push_str(&failure_hint(binary, stderr_tail));
1680 }
1681 reason
1682}
1683
1684fn failure_hint(binary: &str, stderr_tail: &str) -> String {
1685 if stderr_tail.contains("MODULE_NOT_FOUND") || stderr_tail.contains("Cannot find module") {
1686 let package_manager = infer_package_manager(stderr_tail);
1687 format!(
1688 "Your package-manager shim resolves to a missing file. Try reinstalling: {package_manager} install -g {binary} --force. Common cause: hard-link breakage from fs migration or store prune."
1689 )
1690 } else if let Some(component) = rustup_missing_component(stderr_tail) {
1691 format!("'{component}' is a rustup proxy but the component is not installed. Install it: rustup component add {component}")
1696 } else {
1697 format!("Hint: see stderr above for '{binary}' failure details.")
1698 }
1699}
1700
1701fn rustup_missing_component(stderr_tail: &str) -> Option<String> {
1707 let marker = "Unknown binary '";
1708 let start = stderr_tail.find(marker)? + marker.len();
1709 let rest = &stderr_tail[start..];
1710 let end = rest.find('\'')?;
1711 let name = &rest[..end];
1712 if name.is_empty() || !stderr_tail.contains("toolchain") {
1715 return None;
1716 }
1717 Some(name.to_string())
1718}
1719
1720fn infer_package_manager(stderr_tail: &str) -> &'static str {
1721 let lower = stderr_tail.to_ascii_lowercase();
1722 if lower.contains(".pnpm/") || lower.contains(".pnpm\\") || lower.contains("/pnpm/") {
1723 "pnpm"
1724 } else if lower.contains(".yarn/")
1725 || lower.contains(".yarn\\")
1726 || lower.contains("/yarn/")
1727 || lower.contains("yarn")
1728 {
1729 "yarn"
1730 } else {
1731 "npm"
1732 }
1733}
1734
1735fn canonicalize_for_lsp(file_path: &Path) -> Result<PathBuf, LspError> {
1736 std::fs::canonicalize(file_path).map_err(LspError::from)
1737}
1738
1739fn resolve_for_lsp_uri(file_path: &Path) -> PathBuf {
1740 if let Ok(path) = std::fs::canonicalize(file_path) {
1741 return path;
1742 }
1743
1744 let mut existing = file_path.to_path_buf();
1745 let mut missing = Vec::new();
1746 while !existing.exists() {
1747 let Some(name) = existing.file_name() else {
1748 break;
1749 };
1750 missing.push(name.to_owned());
1751 let Some(parent) = existing.parent() else {
1752 break;
1753 };
1754 existing = parent.to_path_buf();
1755 }
1756
1757 let mut resolved = std::fs::canonicalize(&existing).unwrap_or(existing);
1758 for segment in missing.into_iter().rev() {
1759 resolved.push(segment);
1760 }
1761 resolved
1762}
1763
1764fn language_id_for_extension(ext: &str) -> &'static str {
1765 match ext {
1766 "ts" => "typescript",
1767 "tsx" => "typescriptreact",
1768 "js" | "mjs" | "cjs" => "javascript",
1769 "jsx" => "javascriptreact",
1770 "py" | "pyi" => "python",
1771 "rs" => "rust",
1772 "go" => "go",
1773 "html" | "htm" => "html",
1774 _ => "plaintext",
1775 }
1776}
1777
1778fn normalize_lookup_path(path: &Path) -> PathBuf {
1779 std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
1780}
1781
1782fn classify_spawn_error(binary: &str, err: &LspError) -> ServerAttemptResult {
1789 match err {
1790 LspError::NotFound(_) => ServerAttemptResult::BinaryNotInstalled {
1795 binary: binary.to_string(),
1796 },
1797 other => ServerAttemptResult::SpawnFailed {
1798 binary: binary.to_string(),
1799 reason: other.to_string(),
1800 },
1801 }
1802}
1803
1804fn env_binary_override(kind: &ServerKind) -> Option<PathBuf> {
1805 let id = kind.id_str();
1806 let suffix: String = id
1807 .chars()
1808 .map(|ch| {
1809 if ch.is_ascii_alphanumeric() {
1810 ch.to_ascii_uppercase()
1811 } else {
1812 '_'
1813 }
1814 })
1815 .collect();
1816 let key = format!("AFT_LSP_{suffix}_BINARY");
1817 std::env::var_os(key).map(PathBuf::from)
1818}
1819
1820#[cfg(test)]
1821mod failure_hint_tests {
1822 use super::{failure_hint, rustup_missing_component};
1823
1824 #[test]
1825 fn detects_rustup_proxy_without_component() {
1826 let stderr = "error: Unknown binary 'rust-analyzer' in official toolchain 'stable-aarch64-apple-darwin'.";
1828 assert_eq!(
1829 rustup_missing_component(stderr).as_deref(),
1830 Some("rust-analyzer")
1831 );
1832 let hint = failure_hint("rust-analyzer", stderr);
1833 assert!(
1834 hint.contains("rustup component add rust-analyzer"),
1835 "expected actionable rustup hint, got: {hint}"
1836 );
1837 }
1838
1839 #[test]
1840 fn ignores_unknown_binary_without_toolchain_phrasing() {
1841 let stderr = "fatal: Unknown binary 'foo' was requested by the linker.";
1844 assert_eq!(rustup_missing_component(stderr), None);
1845 assert!(failure_hint("foo", stderr).starts_with("Hint: see stderr"));
1846 }
1847
1848 #[test]
1849 fn npm_module_not_found_still_wins() {
1850 let stderr = "Error: Cannot find module '/x/typescript-language-server/lib/cli.mjs'";
1852 let hint = failure_hint("typescript-language-server", stderr);
1853 assert!(hint.contains("install -g"), "got: {hint}");
1854 }
1855}
1856
1857#[cfg(test)]
1858mod clear_diagnostics_tests {
1859 use std::path::PathBuf;
1860
1861 use super::LspManager;
1862 use crate::lsp::diagnostics::{DiagnosticSeverity, StoredDiagnostic};
1863 use crate::lsp::registry::ServerKind;
1864 use crate::lsp::roots::ServerKey;
1865
1866 fn err_diag(file: &PathBuf) -> StoredDiagnostic {
1867 StoredDiagnostic {
1868 file: file.clone(),
1869 line: 1,
1870 column: 1,
1871 end_line: 1,
1872 end_column: 2,
1873 severity: DiagnosticSeverity::Error,
1874 message: "boom".into(),
1875 code: None,
1876 source: None,
1877 }
1878 }
1879
1880 #[test]
1885 fn clear_diagnostics_for_deleted_file_matches_canonical_key() {
1886 let dir = tempfile::tempdir().unwrap();
1887 let canonical_dir = std::fs::canonicalize(dir.path()).unwrap();
1889 let canonical_file = canonical_dir.join("gone.ts");
1890 std::fs::write(&canonical_file, "x").unwrap();
1893
1894 let mut manager = LspManager::new();
1895 let key = ServerKey {
1896 kind: ServerKind::TypeScript,
1897 root: canonical_dir.clone(),
1898 };
1899 manager.diagnostics_store_mut_for_test().publish(
1900 key,
1901 canonical_file.clone(),
1902 vec![err_diag(&canonical_file)],
1903 );
1904 assert_eq!(manager.warm_error_warning_counts(), (1, 0));
1905
1906 std::fs::remove_file(&canonical_file).unwrap();
1907
1908 let watcher_path = dir.path().join("gone.ts");
1911 let removed = manager.clear_diagnostics_for_file(&watcher_path);
1912
1913 assert!(removed, "expected the deleted file's diagnostic to clear");
1914 assert_eq!(manager.warm_error_warning_counts(), (0, 0));
1915 }
1916
1917 #[test]
1918 fn clear_diagnostics_for_unknown_file_is_noop() {
1919 let mut manager = LspManager::new();
1920 assert!(!manager.clear_diagnostics_for_file(&PathBuf::from("/nope/missing.ts")));
1921 assert_eq!(manager.warm_error_warning_counts(), (0, 0));
1922 }
1923}