1use std::sync::{Arc, LazyLock};
5
6use lsp_types::{DocumentDiagnosticParams, Uri};
7use tokio::sync::Mutex;
8use tracing::{debug, info};
9
10use super::availability::{DiagnosticsAnswer, DiagnosticsAvailability, DiagnosticsOrigin};
11use super::dto::{
12 Diagnostic, DiagnosticsResult, DocumentDiagnosticsResult, ServerLogsResult,
13 ServerMessagesResult,
14};
15use super::enclosing::{Contextualized, ResultContext};
16use super::encoding_ctx::EncodingCtx;
17use super::positioned::document_request_classified;
18use super::pull_support::{PullProbe, PullSupport};
19use super::routing::PreparedDocument;
20use super::{NotificationWiring, Translator};
21use crate::bridge::encoding::PositionEncoding;
22use crate::bridge::notifications::{
23 BoundedDiagnostics, ChangeOutcome, LogLevel, PullStamp, PullWrite, ReportedSeverity,
24 SlotChange, SnapshotBefore, VersionCheck, message_as_str, reported_code,
25};
26use crate::bridge::{
27 ClientPath, DiagnosticInfo, DiagnosticSources, DiagnosticsKey, DocumentTracker,
28 NotificationCache, WorkspacePath, WorkspaceRoots, path_to_uri,
29};
30use crate::config::{ServerId, ToolKind};
31use crate::error::{Error, ErrorChain, Result};
32use crate::lsp::{ConnectionId, UnclassifiedError};
33use crate::util::{lock_std, run_detached};
34
35mod sweep;
36#[cfg(test)]
37pub use sweep::MAX_FAILED_PULLS_PER_STATE;
38pub use sweep::{
39 Interest, MAX_COMPANION_FILES, MAX_SWEPT_FILES_PER_TICK, SWEEP_INTERVAL, SweepContext,
40 SweepMemo,
41};
42#[cfg(test)]
43mod sweep_tests;
44
45#[derive(serde::Serialize, serde::Deserialize)]
57#[serde(untagged)]
58enum DocumentDiagnosticReportResult {
59 Report(lsp_types::DocumentDiagnosticReport),
60 Partial(
65 #[allow(
66 dead_code,
67 reason = "the partial-result payload is only deserialized, never read"
68 )]
69 lsp_types::DocumentDiagnosticReportPartialResult,
70 ),
71}
72
73enum PullDiagnosticRequest {}
77
78impl lsp_types::Request for PullDiagnosticRequest {
79 type Params = DocumentDiagnosticParams;
80 type Result = DocumentDiagnosticReportResult;
81 const METHOD: lsp_types::LspRequestMethod<'static> =
82 <lsp_types::DocumentDiagnosticRequest as lsp_types::Request>::METHOD;
83 const MESSAGE_DIRECTION: lsp_types::MessageDirection =
84 <lsp_types::DocumentDiagnosticRequest as lsp_types::Request>::MESSAGE_DIRECTION;
85}
86
87enum PullAttempt {
89 Answered(Option<BoundedDiagnostics>),
91 PushOnly,
94 Failed(Error),
96}
97
98impl PullAttempt {
99 const fn origin(&self) -> DiagnosticsOrigin {
101 match self {
102 Self::Answered(_) => DiagnosticsOrigin::Pull,
103 Self::PushOnly => DiagnosticsOrigin::PushCache,
104 Self::Failed(_) => DiagnosticsOrigin::CacheAfterFailedPull,
105 }
106 }
107}
108
109enum PullReport {
111 Full(Vec<lsp_types::Diagnostic>),
113 NotStored,
116}
117
118impl From<DocumentDiagnosticReportResult> for PullReport {
119 fn from(response: DocumentDiagnosticReportResult) -> Self {
120 match response {
121 DocumentDiagnosticReportResult::Report(
122 lsp_types::DocumentDiagnosticReport::RelatedFullDocumentDiagnosticReport(full),
123 ) => Self::Full(full.full_document_diagnostic_report.items),
124 DocumentDiagnosticReportResult::Report(
125 lsp_types::DocumentDiagnosticReport::RelatedUnchangedDocumentDiagnosticReport(_),
126 )
127 | DocumentDiagnosticReportResult::Partial(_) => Self::NotStored,
128 }
129 }
130}
131
132enum PullStorage {
134 Stored(SlotChange),
136 NotStored,
138}
139
140struct PullSettlement {
142 sources: DiagnosticSources,
144 storage: PullStorage,
145 evicted: Vec<DiagnosticsKey>,
147 availability: DiagnosticsAvailability,
150}
151
152pub(super) struct PullRound {
154 origin: DiagnosticsOrigin,
155 failure: Option<Error>,
157 merged: Option<DiagnosticInfo>,
159 availability: DiagnosticsAvailability,
160}
161
162struct Notices {
164 changed: Option<Uri>,
166 evicted: Vec<DiagnosticsKey>,
168}
169
170impl Notices {
171 const fn is_empty(&self) -> bool {
172 self.changed.is_none() && self.evicted.is_empty()
173 }
174
175 async fn deliver(self, wiring: Arc<dyn NotificationWiring>) {
176 if let Some(file) = &self.changed
177 && wiring.has_subscriptions().await
178 {
179 wiring.publish_changed(file).await;
180 }
181 if !self.evicted.is_empty() {
182 wiring.publish_invalidated(&self.evicted).await;
183 }
184 }
185}
186
187enum ProbeOutcome {
189 Refuse,
191 TimedOut,
193 Transient,
195}
196
197impl ProbeOutcome {
198 fn of(failure: &UnclassifiedError) -> Option<Self> {
201 if failure.timed_out() {
202 return Some(Self::TimedOut);
203 }
204 let code = failure.server_error()?.code;
205 if lsp_types::ErrorCodes::from(code) == lsp_types::ErrorCodes::MethodNotFound {
206 Some(Self::Refuse)
207 } else if matches!(
208 lsp_types::LspErrorCodes::from(code),
209 lsp_types::LspErrorCodes::RequestCancelled
210 | lsp_types::LspErrorCodes::ContentModified
211 | lsp_types::LspErrorCodes::ServerCancelled
212 ) {
213 Some(Self::Transient)
214 } else {
215 None
216 }
217 }
218}
219
220static EMPTY_WORKSPACE_ROOTS: LazyLock<WorkspaceRoots> = LazyLock::new(WorkspaceRoots::default);
223
224pub(super) async fn diagnostic_to_mcp(
230 diag: &lsp_types::Diagnostic,
231 ctx: &EncodingCtx,
232 uri: &lsp_types::Uri,
233) -> Diagnostic {
234 Diagnostic {
235 range: ctx.normalize_range(uri, diag.range).await,
236 severity: ReportedSeverity::of(diag),
237 message: message_as_str(&diag.message).to_string(),
238 code: reported_code(diag).map(std::borrow::Cow::into_owned),
239 }
240}
241
242impl Translator {
243 pub async fn cached_diagnostics_uri(
256 workspace_roots: &WorkspaceRoots,
257 file_path: &ClientPath,
258 ) -> Result<Uri> {
259 Self::cached_diagnostics_path_and_uri(workspace_roots, file_path)
260 .await
261 .map(|(_, uri)| uri)
262 }
263
264 pub(crate) async fn cached_diagnostics_path_and_uri(
275 workspace_roots: &WorkspaceRoots,
276 file_path: &ClientPath,
277 ) -> Result<(WorkspacePath, Uri)> {
278 let validated_path = workspace_roots.validate(file_path).await?;
279
280 let uri = path_to_uri(validated_path.as_path())?;
283 Ok((validated_path, uri))
284 }
285
286 pub async fn handle_diagnostics(
337 &self,
338 file_path: ClientPath,
339 context: ResultContext,
340 notification_cache: &Mutex<NotificationCache>,
341 ) -> Result<DocumentDiagnosticsResult> {
342 let path = self.validate_path(&file_path).await?;
343 self.handle_validated_diagnostics(&path, context, notification_cache)
344 .await
345 .map(|answer| answer.result)
346 }
347
348 pub(crate) async fn handle_validated_diagnostics(
356 &self,
357 path: &WorkspacePath,
358 context: ResultContext,
359 notification_cache: &Mutex<NotificationCache>,
360 ) -> Result<DiagnosticsAnswer> {
361 let doc = self
362 .prepare_document_for_path(path, ToolKind::Diagnostics)
363 .await?;
364 let (server_id, uri) = (doc.server_id(), doc.uri());
365 let ctx = self.encoding_ctx(server_id);
366 let PullRound {
367 origin,
368 failure,
369 merged: diag_info,
370 availability,
371 } = self.pull_round(&doc, notification_cache).await;
372
373 let merged = Self::diagnostics_from_cache_entry(
374 diag_info.as_ref(),
375 ctx.encoding,
376 &self.document_tracker,
377 )
378 .await;
379 if let Some(e) = failure
380 && merged.diagnostics.is_empty()
381 {
382 return Err(e);
383 }
384
385 let Contextualized {
386 items: diagnostics,
387 enrichment,
388 positions_degraded,
389 } = self
390 .contextualize_diagnostics(
391 uri.as_ref(),
392 merged.diagnostics,
393 context,
394 merged.positions_degraded,
395 )
396 .await;
397 Ok(DiagnosticsAnswer {
398 result: DocumentDiagnosticsResult {
399 diagnostics,
400 positions_degraded,
401 enrichment,
402 },
403 availability,
404 origin,
405 })
406 }
407
408 pub(super) async fn pull_round(
418 &self,
419 doc: &PreparedDocument,
420 notification_cache: &Mutex<NotificationCache>,
421 ) -> PullRound {
422 let (server_id, uri) = (doc.server_id(), doc.uri());
423 let support = self.pull_support(server_id);
424
425 let stamp = match self.document_tracker.synced_version(doc.path(), server_id) {
426 Some(version) if support.sends_pull() => Some(
427 notification_cache
428 .lock()
429 .await
430 .begin_pull(server_id, version),
431 ),
432 _ => None,
433 };
434
435 let attempt = self.request_pull(doc, support).await;
436 let origin = attempt.origin();
437 let (pulled, failure) = match attempt {
438 PullAttempt::Answered(pulled) => (pulled, None),
439 PullAttempt::PushOnly => (None, None),
440 PullAttempt::Failed(e) => (None, Some(e)),
441 };
442
443 let wiring = self.wiring.get().map(Arc::clone);
444 let snapshot_before = match &wiring {
445 Some(wiring) if wiring.has_subscriptions().await => SnapshotBefore::Take,
446 _ => SnapshotBefore::Skip,
447 };
448 let PullSettlement {
449 sources,
450 storage,
451 evicted,
452 availability,
453 } = {
454 let mut cache = notification_cache.lock().await;
455 self.settle_pull(&mut cache, doc, stamp, pulled, snapshot_before)
456 };
457 let merged = sources.merge();
458
459 if let Some(wiring) = wiring {
460 let outcome = match storage {
461 PullStorage::Stored(SlotChange::Replaced { before }) => before
462 .map_or(ChangeOutcome::Changed, |before| {
463 ChangeOutcome::of(before.merge().as_ref(), merged.as_ref())
464 }),
465 PullStorage::Stored(SlotChange::Identical) | PullStorage::NotStored => {
466 ChangeOutcome::Unchanged
467 }
468 };
469 let notices = Notices {
470 changed: (outcome == ChangeOutcome::Changed).then(|| uri.clone()),
471 evicted,
472 };
473 if !notices.is_empty()
474 && let Err(error) = run_detached(notices.deliver(wiring)).await
475 {
476 debug!("diagnostics notifications were cancelled: {error}");
477 }
478 }
479
480 PullRound {
481 origin,
482 failure,
483 merged,
484 availability,
485 }
486 }
487
488 async fn request_pull(&self, doc: &PreparedDocument, support: PullSupport) -> PullAttempt {
494 if !support.sends_pull() {
495 return PullAttempt::PushOnly;
496 }
497 let (server_id, client, uri) = (doc.server_id(), doc.client(), doc.uri());
498 let sent_with = if support == PullSupport::Probing {
499 self.pull_probe_of(server_id)
500 } else {
501 PullProbe::default()
502 };
503 let response = document_request_classified::<PullDiagnosticRequest>(doc, ()).await;
504 match response {
505 Ok(response) => {
506 if support == PullSupport::Probing {
507 self.record_pull_probe(server_id, client.connection_id(), |_| {
508 PullProbe::Answered
509 });
510 }
511 PullAttempt::Answered(match PullReport::from(response) {
512 PullReport::Full(mut items) => {
513 let redactions = client.redactions();
514 if !redactions.is_empty() {
515 for d in &mut items {
516 redactions.redact_diagnostic(d);
517 }
518 }
519 Some(BoundedDiagnostics::new(uri, items))
520 }
521 PullReport::NotStored => None,
522 })
523 }
524 Err(unclassified) if support == PullSupport::Probing => {
525 self.conclude_probe(server_id, client.connection_id(), sent_with, unclassified)
526 }
527 Err(unclassified) => PullAttempt::Failed(unclassified.surface()),
528 }
529 }
530
531 fn conclude_probe(
538 &self,
539 server_id: &ServerId,
540 conn: ConnectionId,
541 sent_with: PullProbe,
542 unclassified: UnclassifiedError,
543 ) -> PullAttempt {
544 let Some(outcome) = ProbeOutcome::of(&unclassified) else {
545 return PullAttempt::Failed(unclassified.surface());
546 };
547 let error = unclassified.handled();
548 match outcome {
549 ProbeOutcome::Refuse => {
550 debug!(
551 %server_id,
552 error = %ErrorChain(&error),
553 "server advertises no diagnostic provider and refused a pull; \
554 answering from the push cache"
555 );
556 self.record_pull_probe(server_id, conn, |_| PullProbe::Refused);
557 }
558 ProbeOutcome::TimedOut => {
559 let probe =
560 self.record_pull_probe(server_id, conn, |now| now.after_timeout(sent_with));
561 if probe == Some(PullProbe::Refused) {
562 info!(
563 %server_id,
564 error = %ErrorChain(&error),
565 "server advertises no diagnostic provider and timed out on two pulls \
566 in a row; answering from the push cache until it is replaced \
567 (restart_server)"
568 );
569 } else {
570 debug!(%server_id, error = %ErrorChain(&error), "probe pull timed out; answering from the push cache");
571 }
572 }
573 ProbeOutcome::Transient => debug!(
574 %server_id,
575 error = %ErrorChain(&error),
576 "probe pull could not be answered right now; answering from the push cache"
577 ),
578 }
579 PullAttempt::PushOnly
580 }
581
582 pub(super) fn pull_support(&self, id: &ServerId) -> PullSupport {
584 lock_std(&self.servers).pull_support(id)
585 }
586
587 fn pull_probe_of(&self, id: &ServerId) -> PullProbe {
588 lock_std(&self.servers).pull_probe(id)
589 }
590
591 fn record_pull_probe(
592 &self,
593 id: &ServerId,
594 conn: ConnectionId,
595 next: impl FnOnce(PullProbe) -> PullProbe,
596 ) -> Option<PullProbe> {
597 lock_std(&self.servers).update_pull_probe(id, conn, next)
598 }
599
600 fn settle_pull(
604 &self,
605 cache: &mut NotificationCache,
606 doc: &PreparedDocument,
607 stamp: Option<PullStamp>,
608 pulled: Option<BoundedDiagnostics>,
609 snapshot_before: SnapshotBefore,
610 ) -> PullSettlement {
611 let (server_id, uri) = (doc.server_id(), doc.uri());
612 let unstored = |cache: &NotificationCache, items, version| PullSettlement {
613 sources: cache
614 .diagnostic_sources(uri)
615 .with_pulled(uri, version, items),
616 storage: PullStorage::NotStored,
617 evicted: Vec::new(),
618 availability: DiagnosticsAvailability::Published,
619 };
620 let Some(items) = pulled else {
621 return PullSettlement {
622 sources: cache.diagnostic_sources(uri),
623 storage: PullStorage::NotStored,
624 evicted: Vec::new(),
625 availability: cache.availability(uri, Some(server_id)),
626 };
627 };
628 let Some(stamp) = stamp else {
629 return unstored(cache, items, None);
630 };
631 let check = if self.document_tracker.synced_version(doc.path(), server_id)
632 == Some(stamp.version())
633 {
634 VersionCheck::Current
635 } else {
636 VersionCheck::Moved
637 };
638 match cache.store_pulled_diagnostics_with(
639 server_id,
640 uri,
641 stamp,
642 check,
643 items,
644 snapshot_before,
645 ) {
646 PullWrite::Stored { slot, evicted } => PullSettlement {
647 sources: cache.diagnostic_sources(uri),
648 storage: PullStorage::Stored(slot),
649 evicted,
650 availability: DiagnosticsAvailability::Published,
651 },
652 PullWrite::Discarded {
653 reason,
654 evicted,
655 items,
656 } => {
657 debug!(
658 "discarding the pulled diagnostics of {}: {reason:?}",
659 uri.as_ref()
660 );
661 PullSettlement {
662 evicted,
663 ..unstored(cache, items, Some(stamp.version()))
664 }
665 }
666 }
667 }
668
669 #[must_use]
680 pub async fn diagnostics_from_cache_entry(
681 diag_info: Option<&DiagnosticInfo>,
682 encoding: PositionEncoding,
683 tracker: &Arc<DocumentTracker>,
684 ) -> DiagnosticsResult {
685 match diag_info {
686 Some(diag_info) => {
687 let ctx =
690 EncodingCtx::new(encoding, tracker.clone(), EMPTY_WORKSPACE_ROOTS.clone());
691 let mut result = Vec::with_capacity(diag_info.diagnostics.len());
692 for d in &diag_info.diagnostics {
693 result.push(diagnostic_to_mcp(d, &ctx, &diag_info.uri).await);
694 }
695 DiagnosticsResult {
696 diagnostics: result,
697 positions_degraded: ctx.positions_degraded(),
698 }
699 }
700 None => DiagnosticsResult {
701 diagnostics: Vec::new(),
702 positions_degraded: None,
703 },
704 }
705 }
706
707 #[must_use]
712 pub fn handle_server_logs(
713 cache: &NotificationCache,
714 limit: usize,
715 min_level: Option<LogLevel>,
716 ) -> ServerLogsResult {
717 let logs = cache
718 .logs()
719 .iter()
720 .filter(|log| min_level.is_none_or(|min| log.level.meets(min)))
721 .take(limit)
722 .cloned()
723 .collect();
724
725 ServerLogsResult { logs }
726 }
727
728 pub fn handle_server_messages(
734 cache: &NotificationCache,
735 limit: usize,
736 ) -> Result<ServerMessagesResult> {
737 let all_messages = cache.messages();
738 let messages: Vec<_> = all_messages.iter().take(limit).cloned().collect();
739 Ok(ServerMessagesResult { messages })
740 }
741}
742
743#[cfg(test)]
744mod pull_tests;
745
746#[cfg(test)]
747mod tests {
748 use std::collections::HashMap;
749 use std::sync::Arc;
750 use std::time::Duration;
751 use std::{assert_matches, fs};
752
753 use lsp_types::{PartialResultParams, TextDocumentIdentifier, WorkDoneProgressParams};
754 use tempfile::TempDir;
755 use tokio::io::BufReader;
756 use tokio::time::timeout;
757 use url::Url;
758
759 use super::*;
760 use crate::bridge::translator::dto::PositionDegradation;
761 use crate::bridge::translator::testing::*;
762 use crate::config::{FileExtension, LanguageId, ServerId, ToolRouter};
763 use crate::error::Error;
764 use crate::test_lsp::client_path;
765
766 #[test]
772 fn test_document_diagnostic_params_omit_optional_null_fields() {
773 let uri = lsp_types::Uri::from("file:///test.ts");
774 let params = DocumentDiagnosticParams {
775 text_document: TextDocumentIdentifier { uri },
776 identifier: None,
777 previous_result_id: None,
778 work_done_progress_params: WorkDoneProgressParams::default(),
779 partial_result_params: PartialResultParams::default(),
780 };
781 let value = serde_json::to_value(params).unwrap();
782
783 assert_eq!(value["textDocument"]["uri"], "file:///test.ts");
784 assert!(value.get("identifier").is_none());
785 assert!(value.get("previousResultId").is_none());
786 }
787
788 #[tokio::test]
789 async fn test_handle_cached_diagnostics_empty() {
790 let cache = NotificationCache::new();
791 let temp_dir = TempDir::new().unwrap();
792 let test_file = temp_dir.path().join("test.rs");
793 fs::write(&test_file, "fn main() {}").unwrap();
794
795 let cache_key = Translator::cached_diagnostics_uri(
796 &WorkspaceRoots::from_paths(&[temp_dir.path().to_path_buf()]).unwrap(),
797 &client_path(&test_file),
798 )
799 .await
800 .unwrap();
801 let diag_info = cache.diagnostics(&cache_key);
802 let diags = Translator::diagnostics_from_cache_entry(
803 diag_info.as_ref(),
804 PositionEncoding::Utf16,
805 &test_tracker(),
806 )
807 .await;
808 assert_eq!(diags.diagnostics.len(), 0);
809 }
810
811 #[test]
812 fn test_handle_server_logs_with_filter() {
813 let mut cache = NotificationCache::new();
814
815 cache.store_log(LogLevel::Error, "error msg".to_string());
817 cache.store_log(LogLevel::Warning, "warning msg".to_string());
818 cache.store_log(LogLevel::Info, "info msg".to_string());
819 cache.store_log(LogLevel::Debug, "debug msg".to_string());
820
821 let logs = Translator::handle_server_logs(&cache, 10, Some(LogLevel::Error));
823 assert_eq!(logs.logs.len(), 1);
824 assert_eq!(logs.logs[0].message, "error msg");
825
826 let logs = Translator::handle_server_logs(&cache, 10, Some(LogLevel::Warning));
828 assert_eq!(logs.logs.len(), 2);
829
830 let logs = Translator::handle_server_logs(&cache, 10, Some(LogLevel::Info));
832 assert_eq!(logs.logs.len(), 3);
833
834 let logs = Translator::handle_server_logs(&cache, 10, Some(LogLevel::Debug));
836 assert_eq!(logs.logs.len(), 4);
837 }
838
839 #[test]
840 fn test_handle_server_messages_limit() {
841 use crate::bridge::notifications::MessageType;
842
843 let mut cache = NotificationCache::new();
844
845 for i in 0..10 {
847 cache.store_message(MessageType::Info, format!("message {i}"));
848 }
849
850 let result = Translator::handle_server_messages(&cache, 5);
852 assert!(result.is_ok());
853 let messages = result.unwrap();
854 assert_eq!(messages.messages.len(), 5);
855 assert_eq!(messages.messages[0].message, "message 0");
856 assert_eq!(messages.messages[4].message, "message 4");
857
858 let result = Translator::handle_server_messages(&cache, 100);
860 assert!(result.is_ok());
861 let messages = result.unwrap();
862 assert_eq!(messages.messages.len(), 10);
863 }
864
865 #[tokio::test]
866 async fn test_handle_cached_diagnostics_with_data() {
867 let mut cache = NotificationCache::new();
868 let temp_dir = TempDir::new().unwrap();
869 let test_file = temp_dir.path().join("test.rs");
870 fs::write(&test_file, "fn main() {}").unwrap();
871
872 let canonical_path = test_file.canonicalize().unwrap();
873 let uri: lsp_types::Uri =
874 lsp_types::Uri::from(Url::from_file_path(&canonical_path).unwrap().as_str());
875 let diagnostic = lsp_types::Diagnostic {
876 range: lsp_types::Range {
877 start: lsp_types::Position {
878 line: 0,
879 character: 0,
880 },
881 end: lsp_types::Position {
882 line: 0,
883 character: 5,
884 },
885 },
886 severity: Some(lsp_types::DiagnosticSeverity::Error),
887 message: "test error".to_string().into(),
888 code: Some(lsp_types::Code::String("E001".to_string())),
889 source: None,
890 code_description: None,
891 related_information: None,
892 tags: None,
893 data: None,
894 };
895
896 cache.store_diagnostics(
897 &ServerId::from_static("rust"),
898 &uri,
899 Some(1),
900 vec![diagnostic],
901 );
902
903 let cache_key = Translator::cached_diagnostics_uri(
904 &WorkspaceRoots::from_paths(&[temp_dir.path().to_path_buf()]).unwrap(),
905 &client_path(&test_file),
906 )
907 .await
908 .unwrap();
909 let diag_info = cache.diagnostics(&cache_key);
910 let diags = Translator::diagnostics_from_cache_entry(
911 diag_info.as_ref(),
912 PositionEncoding::Utf16,
913 &test_tracker(),
914 )
915 .await;
916 assert_eq!(diags.diagnostics.len(), 1);
917 assert_eq!(diags.diagnostics[0].message, "test error");
918 assert_eq!(diags.diagnostics[0].code, Some("E001".to_string()));
919 assert_matches!(diags.diagnostics[0].severity, ReportedSeverity::Error);
920 assert_eq!(diags.diagnostics[0].range.start.line, 1);
921 assert_eq!(diags.diagnostics[0].range.start.character, 1);
922 }
923
924 #[tokio::test]
925 #[allow(
926 clippy::too_many_lines,
927 reason = "one scenario covering every severity"
928 )]
929 async fn test_handle_cached_diagnostics_multiple_severities() {
930 let mut cache = NotificationCache::new();
931 let temp_dir = TempDir::new().unwrap();
932 let test_file = temp_dir.path().join("test.rs");
933 fs::write(&test_file, "fn main() {}").unwrap();
934
935 let canonical_path = test_file.canonicalize().unwrap();
936 let uri: lsp_types::Uri =
937 lsp_types::Uri::from(Url::from_file_path(&canonical_path).unwrap().as_str());
938 let diagnostics = vec![
939 lsp_types::Diagnostic {
940 range: lsp_types::Range {
941 start: lsp_types::Position {
942 line: 0,
943 character: 0,
944 },
945 end: lsp_types::Position {
946 line: 0,
947 character: 5,
948 },
949 },
950 severity: Some(lsp_types::DiagnosticSeverity::Error),
951 message: "error".to_string().into(),
952 code: None,
953 source: None,
954 code_description: None,
955 related_information: None,
956 tags: None,
957 data: None,
958 },
959 lsp_types::Diagnostic {
960 range: lsp_types::Range {
961 start: lsp_types::Position {
962 line: 1,
963 character: 0,
964 },
965 end: lsp_types::Position {
966 line: 1,
967 character: 5,
968 },
969 },
970 severity: Some(lsp_types::DiagnosticSeverity::Warning),
971 message: "warning".to_string().into(),
972 code: None,
973 source: None,
974 code_description: None,
975 related_information: None,
976 tags: None,
977 data: None,
978 },
979 lsp_types::Diagnostic {
980 range: lsp_types::Range {
981 start: lsp_types::Position {
982 line: 2,
983 character: 0,
984 },
985 end: lsp_types::Position {
986 line: 2,
987 character: 5,
988 },
989 },
990 severity: Some(lsp_types::DiagnosticSeverity::Information),
991 message: "info".to_string().into(),
992 code: None,
993 source: None,
994 code_description: None,
995 related_information: None,
996 tags: None,
997 data: None,
998 },
999 lsp_types::Diagnostic {
1000 range: lsp_types::Range {
1001 start: lsp_types::Position {
1002 line: 3,
1003 character: 0,
1004 },
1005 end: lsp_types::Position {
1006 line: 3,
1007 character: 5,
1008 },
1009 },
1010 severity: Some(lsp_types::DiagnosticSeverity::Hint),
1011 message: "hint".to_string().into(),
1012 code: None,
1013 source: None,
1014 code_description: None,
1015 related_information: None,
1016 tags: None,
1017 data: None,
1018 },
1019 ];
1020
1021 cache.store_diagnostics(&ServerId::from_static("rust"), &uri, Some(1), diagnostics);
1022
1023 let cache_key = Translator::cached_diagnostics_uri(
1024 &WorkspaceRoots::from_paths(&[temp_dir.path().to_path_buf()]).unwrap(),
1025 &client_path(&test_file),
1026 )
1027 .await
1028 .unwrap();
1029 let diag_info = cache.diagnostics(&cache_key);
1030 let diags = Translator::diagnostics_from_cache_entry(
1031 diag_info.as_ref(),
1032 PositionEncoding::Utf16,
1033 &test_tracker(),
1034 )
1035 .await;
1036 assert_eq!(diags.diagnostics.len(), 4);
1037 assert_matches!(diags.diagnostics[0].severity, ReportedSeverity::Error);
1038 assert_matches!(diags.diagnostics[1].severity, ReportedSeverity::Warning);
1039 assert_matches!(diags.diagnostics[2].severity, ReportedSeverity::Information);
1040 assert_matches!(diags.diagnostics[3].severity, ReportedSeverity::Hint);
1041 }
1042
1043 #[tokio::test]
1044 async fn test_handle_cached_diagnostics_with_numeric_code() {
1045 let mut cache = NotificationCache::new();
1046 let temp_dir = TempDir::new().unwrap();
1047 let test_file = temp_dir.path().join("test.rs");
1048 fs::write(&test_file, "fn main() {}").unwrap();
1049
1050 let canonical_path = test_file.canonicalize().unwrap();
1051 let uri: lsp_types::Uri =
1052 lsp_types::Uri::from(Url::from_file_path(&canonical_path).unwrap().as_str());
1053 let diagnostic = lsp_types::Diagnostic {
1054 range: lsp_types::Range {
1055 start: lsp_types::Position {
1056 line: 0,
1057 character: 0,
1058 },
1059 end: lsp_types::Position {
1060 line: 0,
1061 character: 5,
1062 },
1063 },
1064 severity: Some(lsp_types::DiagnosticSeverity::Error),
1065 message: "test error".to_string().into(),
1066 code: Some(lsp_types::Code::Int(42)),
1067 source: None,
1068 code_description: None,
1069 related_information: None,
1070 tags: None,
1071 data: None,
1072 };
1073
1074 cache.store_diagnostics(
1075 &ServerId::from_static("rust"),
1076 &uri,
1077 Some(1),
1078 vec![diagnostic],
1079 );
1080
1081 let cache_key = Translator::cached_diagnostics_uri(
1082 &WorkspaceRoots::from_paths(&[temp_dir.path().to_path_buf()]).unwrap(),
1083 &client_path(&test_file),
1084 )
1085 .await
1086 .unwrap();
1087 let diag_info = cache.diagnostics(&cache_key);
1088 let diags = Translator::diagnostics_from_cache_entry(
1089 diag_info.as_ref(),
1090 PositionEncoding::Utf16,
1091 &test_tracker(),
1092 )
1093 .await;
1094 assert_eq!(diags.diagnostics.len(), 1);
1095 assert_eq!(diags.diagnostics[0].code, Some("42".to_string()));
1096 }
1097
1098 #[tokio::test]
1099 async fn test_handle_cached_diagnostics_invalid_path() {
1100 let dir = tempfile::TempDir::new().unwrap();
1101 let missing = dir.path().join("nonexistent/path/file.rs");
1102 let result = Translator::cached_diagnostics_uri(
1103 &WorkspaceRoots::from_paths(&[dir.path().to_path_buf()]).unwrap(),
1104 &client_path(&missing),
1105 )
1106 .await;
1107 assert_matches!(result, Err(Error::FileIo { .. }));
1108 }
1109
1110 #[tokio::test]
1113 async fn test_diagnostics_from_cache_entry_flags_degraded_positions() {
1114 let info = diag_info(vec![lsp_diag(
1115 0,
1116 10,
1117 lsp_types::DiagnosticSeverity::Warning,
1118 "unused import: `std::fmt`",
1119 None,
1120 )]);
1121
1122 let result = Translator::diagnostics_from_cache_entry(
1123 Some(&info),
1124 PositionEncoding::Utf8,
1125 &test_tracker(),
1126 )
1127 .await;
1128
1129 assert_eq!(
1130 result.positions_degraded,
1131 Some(PositionDegradation::Response)
1132 );
1133 let utf16 = Translator::diagnostics_from_cache_entry(
1134 Some(&info),
1135 PositionEncoding::Utf16,
1136 &test_tracker(),
1137 )
1138 .await;
1139 assert_eq!(utf16.positions_degraded, None);
1140 }
1141
1142 #[test]
1143 fn test_handle_server_logs_no_filter() {
1144 let mut cache = NotificationCache::new();
1145
1146 cache.store_log(LogLevel::Error, "error msg".to_string());
1147 cache.store_log(LogLevel::Warning, "warning msg".to_string());
1148 cache.store_log(LogLevel::Info, "info msg".to_string());
1149 cache.store_log(LogLevel::Debug, "debug msg".to_string());
1150
1151 let logs = Translator::handle_server_logs(&cache, 10, None);
1152 assert_eq!(logs.logs.len(), 4);
1153 }
1154
1155 #[test]
1156 fn test_handle_server_logs_error_filter_strict() {
1157 let mut cache = NotificationCache::new();
1158
1159 cache.store_log(LogLevel::Error, "error msg".to_string());
1160 cache.store_log(LogLevel::Warning, "warning msg".to_string());
1161 cache.store_log(LogLevel::Info, "info msg".to_string());
1162
1163 let logs = Translator::handle_server_logs(&cache, 10, Some(LogLevel::Error));
1164 assert_eq!(logs.logs.len(), 1);
1165 assert_eq!(logs.logs[0].message, "error msg");
1166 }
1167
1168 #[test]
1169 fn test_handle_server_logs_warning_filter_includes_errors() {
1170 let mut cache = NotificationCache::new();
1171
1172 cache.store_log(LogLevel::Error, "error msg".to_string());
1173 cache.store_log(LogLevel::Warning, "warning msg".to_string());
1174 cache.store_log(LogLevel::Info, "info msg".to_string());
1175
1176 let logs = Translator::handle_server_logs(&cache, 10, Some(LogLevel::Warning));
1177 assert_eq!(logs.logs.len(), 2);
1178 }
1179
1180 #[test]
1181 fn test_handle_server_logs_info_filter_excludes_debug() {
1182 let mut cache = NotificationCache::new();
1183
1184 cache.store_log(LogLevel::Error, "error msg".to_string());
1185 cache.store_log(LogLevel::Info, "info msg".to_string());
1186 cache.store_log(LogLevel::Debug, "debug msg".to_string());
1187
1188 let logs = Translator::handle_server_logs(&cache, 10, Some(LogLevel::Info));
1189 assert_eq!(logs.logs.len(), 2);
1190 }
1191
1192 #[test]
1193 fn test_handle_server_logs_debug_filter_includes_all() {
1194 let mut cache = NotificationCache::new();
1195
1196 cache.store_log(LogLevel::Error, "error msg".to_string());
1197 cache.store_log(LogLevel::Warning, "warning msg".to_string());
1198 cache.store_log(LogLevel::Info, "info msg".to_string());
1199 cache.store_log(LogLevel::Debug, "debug msg".to_string());
1200
1201 let logs = Translator::handle_server_logs(&cache, 10, Some(LogLevel::Debug));
1202 assert_eq!(logs.logs.len(), 4);
1203 }
1204
1205 #[test]
1206 fn test_handle_server_logs_limit_applies_after_filter() {
1207 let mut cache = NotificationCache::new();
1208
1209 for i in 0..10 {
1210 cache.store_log(LogLevel::Error, format!("error {i}"));
1211 }
1212
1213 let logs = Translator::handle_server_logs(&cache, 5, Some(LogLevel::Error));
1214 assert_eq!(logs.logs.len(), 5);
1215 assert_eq!(logs.logs[0].message, "error 0");
1216 assert_eq!(logs.logs[4].message, "error 4");
1217 }
1218
1219 #[test]
1220 fn test_handle_server_messages_empty() {
1221 let cache = NotificationCache::new();
1222
1223 let result = Translator::handle_server_messages(&cache, 10);
1224 assert!(result.is_ok());
1225 let messages = result.unwrap();
1226 assert_eq!(messages.messages.len(), 0);
1227 }
1228
1229 #[test]
1230 fn test_handle_server_messages_with_different_types() {
1231 use crate::bridge::notifications::MessageType;
1232
1233 let mut cache = NotificationCache::new();
1234
1235 cache.store_message(MessageType::Error, "error".to_string());
1236 cache.store_message(MessageType::Warning, "warning".to_string());
1237 cache.store_message(MessageType::Info, "info".to_string());
1238 cache.store_message(MessageType::Log, "log".to_string());
1239
1240 let result = Translator::handle_server_messages(&cache, 10);
1241 assert!(result.is_ok());
1242 let messages = result.unwrap();
1243 assert_eq!(messages.messages.len(), 4);
1244 assert_eq!(messages.messages[0].message, "error");
1245 assert_eq!(messages.messages[1].message, "warning");
1246 assert_eq!(messages.messages[2].message, "info");
1247 assert_eq!(messages.messages[3].message, "log");
1248 }
1249
1250 #[test]
1251 fn test_handle_server_messages_zero_limit() {
1252 use crate::bridge::notifications::MessageType;
1253
1254 let mut cache = NotificationCache::new();
1255
1256 cache.store_message(MessageType::Info, "test".to_string());
1257
1258 let result = Translator::handle_server_messages(&cache, 0);
1259 assert!(result.is_ok());
1260 let messages = result.unwrap();
1261 assert_eq!(messages.messages.len(), 0);
1262 }
1263
1264 #[tokio::test]
1265 async fn test_handle_cached_diagnostics_path_outside_workspace() {
1266 let temp_dir1 = TempDir::new().unwrap();
1267 let temp_dir2 = TempDir::new().unwrap();
1268
1269 let workspace_roots = vec![temp_dir1.path().to_path_buf()];
1270
1271 let test_file = temp_dir2.path().join("test.rs");
1272 fs::write(&test_file, "fn main() {}").unwrap();
1273
1274 let result = Translator::cached_diagnostics_uri(
1275 &WorkspaceRoots::from_paths(&workspace_roots).unwrap(),
1276 &client_path(&test_file),
1277 )
1278 .await;
1279 assert_matches!(result, Err(Error::PathOutsideWorkspace(_)));
1280 }
1281
1282 #[tokio::test]
1285 async fn test_handle_diagnostics_redacts_pull_items_before_merging_with_the_cache() {
1286 let dir = TempDir::new().unwrap();
1287 let mut translator = Translator::new()
1288 .with_extensions(crate::test_lsp::test_extensions())
1289 .with_router(ToolRouter::catch_all([(
1290 ServerId::from_static("rust"),
1291 LanguageId::from_static("rust"),
1292 )]));
1293 translator
1294 .set_workspace_roots(WorkspaceRoots::from_paths(&[dir.path().to_path_buf()]).unwrap());
1295 let redactions = crate::redaction::Redactions::new([(
1296 "API_TOKEN".to_owned(),
1297 "SuperSecretValue123".to_owned(),
1298 )]);
1299 let (client, mut server, _lanes) =
1300 crate::test_lsp::fake_lsp_client_with_redactions(redactions);
1301 translator.register_client(ServerId::from_static("rust"), client);
1302
1303 let path = dir.path().join("lib.rs");
1304 fs::write(&path, "fn main() {}").unwrap();
1305 let path_str = path.to_string_lossy().to_string();
1306 let uri = path_to_uri(&path.canonicalize().unwrap()).unwrap();
1307 let notification_cache = Mutex::new(NotificationCache::new());
1308 notification_cache.lock().await.store_diagnostics(
1309 &ServerId::from_static("rust"),
1310 &uri,
1311 Some(1),
1312 vec![lsp_diag(
1313 0,
1314 4,
1315 lsp_types::DiagnosticSeverity::Warning,
1316 "leaked [redacted:API_TOKEN]",
1317 None,
1318 )],
1319 );
1320
1321 let translator = Arc::new(translator);
1322 let handle = {
1323 let translator = Arc::clone(&translator);
1324 tokio::spawn(async move {
1325 translator
1326 .handle_diagnostics(
1327 client_path(path_str),
1328 ResultContext::None,
1329 ¬ification_cache,
1330 )
1331 .await
1332 })
1333 };
1334
1335 let mut wire = BufReader::new(&mut server.write_stdout);
1336 let opened = read_framed_message(&mut wire).await;
1337 assert_eq!(opened["method"], "textDocument/didOpen");
1338 let request = read_framed_message(&mut wire).await;
1339 assert_eq!(request["method"], "textDocument/diagnostic");
1340 let range = serde_json::json!({
1341 "start": {"line": 0, "character": 0},
1342 "end": {"line": 0, "character": 4}
1343 });
1344 write_response(
1345 &mut server.read_half_stdin,
1346 &request["id"],
1347 serde_json::json!({
1348 "kind": "full",
1349 "items": [
1350 {"range": range, "severity": 2, "message": "leaked SuperSecretValue123"},
1351 {
1352 "range": range,
1353 "severity": 1,
1354 "message": {"kind": "plaintext", "value": "other SuperSecretValue123"},
1355 "data": {"hint": "SuperSecretValue123"}
1356 }
1357 ]
1358 }),
1359 )
1360 .await;
1361
1362 let result = timeout(Duration::from_secs(2), handle)
1363 .await
1364 .unwrap()
1365 .unwrap()
1366 .unwrap();
1367
1368 let shown = serde_json::to_string(&result.diagnostics).unwrap();
1369 assert!(!shown.contains("SuperSecretValue123"), "{shown}");
1370 assert_eq!(result.diagnostics.len(), 2, "{shown}");
1371 assert!(shown.contains("leaked [redacted:API_TOKEN]"), "{shown}");
1372 }
1373
1374 #[tokio::test]
1379 async fn test_handle_diagnostics_pull_error_falls_back_to_nonempty_cache() {
1380 let dir = TempDir::new().unwrap();
1381 let mut extensions = HashMap::new();
1382 extensions.insert(
1383 FileExtension::from_static("rs"),
1384 LanguageId::from_static("rust"),
1385 );
1386
1387 let mut translator =
1388 Translator::new()
1389 .with_extensions(extensions)
1390 .with_router(ToolRouter::catch_all([(
1391 ServerId::from_static("rust"),
1392 LanguageId::from_static("rust"),
1393 )]));
1394 translator
1395 .set_workspace_roots(WorkspaceRoots::from_paths(&[dir.path().to_path_buf()]).unwrap());
1396
1397 let (client, mut server) = fake_lsp_client();
1398 translator.register_client(ServerId::from_static("rust"), client);
1399
1400 let path = dir.path().join("lib.rs");
1401 fs::write(&path, "fn main() {}").unwrap();
1402 let path_str = path.to_string_lossy().to_string();
1403
1404 let canonical = path.canonicalize().unwrap();
1408 let uri = path_to_uri(&canonical).unwrap();
1409 let notification_cache = Mutex::new(NotificationCache::new());
1410 {
1411 let mut cache = notification_cache.lock().await;
1412 cache.store_diagnostics(
1413 &ServerId::from_static("rust"),
1414 &uri,
1415 Some(1),
1416 vec![lsp_diag(
1417 0,
1418 4,
1419 lsp_types::DiagnosticSeverity::Warning,
1420 "unused import: `std::fmt`",
1421 None,
1422 )],
1423 );
1424 }
1425
1426 let translator = Arc::new(translator);
1427 let handle = {
1428 let translator = Arc::clone(&translator);
1429 tokio::spawn(async move {
1430 translator
1431 .handle_diagnostics(
1432 client_path(path_str),
1433 ResultContext::None,
1434 ¬ification_cache,
1435 )
1436 .await
1437 })
1438 };
1439
1440 let mut wire = BufReader::new(&mut server.write_stdout);
1441 let opened = read_framed_message(&mut wire).await;
1442 assert_eq!(opened["method"], "textDocument/didOpen");
1443 let diag_request = read_framed_message(&mut wire).await;
1444 assert_eq!(diag_request["method"], "textDocument/diagnostic");
1445 write_error_response(
1446 &mut server.read_half_stdin,
1447 &diag_request["id"],
1448 -32601,
1449 "method not found",
1450 )
1451 .await;
1452
1453 let result = timeout(Duration::from_secs(2), handle)
1454 .await
1455 .expect("handler call should not hang")
1456 .unwrap();
1457
1458 let diagnostics = result.expect("cache-only fallback should succeed despite pull error");
1459 assert_eq!(diagnostics.diagnostics.len(), 1);
1460 assert_eq!(
1461 diagnostics.diagnostics[0].message,
1462 "unused import: `std::fmt`"
1463 );
1464 }
1465
1466 #[tokio::test]
1475 async fn test_handle_diagnostics_partial_response_degrades_to_empty_result() {
1476 let dir = TempDir::new().unwrap();
1477 let mut extensions = HashMap::new();
1478 extensions.insert(
1479 FileExtension::from_static("rs"),
1480 LanguageId::from_static("rust"),
1481 );
1482
1483 let mut translator =
1484 Translator::new()
1485 .with_extensions(extensions)
1486 .with_router(ToolRouter::catch_all([(
1487 ServerId::from_static("rust"),
1488 LanguageId::from_static("rust"),
1489 )]));
1490 translator
1491 .set_workspace_roots(WorkspaceRoots::from_paths(&[dir.path().to_path_buf()]).unwrap());
1492
1493 let (client, mut server) = fake_lsp_client();
1494 translator.register_client(ServerId::from_static("rust"), client);
1495
1496 let path = dir.path().join("lib.rs");
1497 fs::write(&path, "fn main() {}").unwrap();
1498 let path_str = path.to_string_lossy().to_string();
1499
1500 let notification_cache = Mutex::new(NotificationCache::new());
1501
1502 let translator = Arc::new(translator);
1503 let handle = {
1504 let translator = Arc::clone(&translator);
1505 tokio::spawn(async move {
1506 translator
1507 .handle_diagnostics(
1508 client_path(path_str),
1509 ResultContext::None,
1510 ¬ification_cache,
1511 )
1512 .await
1513 })
1514 };
1515
1516 let mut wire = BufReader::new(&mut server.write_stdout);
1517 let opened = read_framed_message(&mut wire).await;
1518 assert_eq!(opened["method"], "textDocument/didOpen");
1519 let diag_request = read_framed_message(&mut wire).await;
1520 assert_eq!(diag_request["method"], "textDocument/diagnostic");
1521
1522 write_response(
1525 &mut server.read_half_stdin,
1526 &diag_request["id"],
1527 serde_json::json!({ "relatedDocuments": {} }),
1528 )
1529 .await;
1530
1531 let result = timeout(Duration::from_secs(2), handle)
1532 .await
1533 .expect("handler call should not hang")
1534 .unwrap();
1535
1536 let diagnostics =
1537 result.expect("a Partial response must degrade to Ok(empty), not an error");
1538 assert!(
1539 diagnostics.diagnostics.is_empty(),
1540 "expected no diagnostics from a Partial response, got {diagnostics:?}"
1541 );
1542 }
1543
1544 #[tokio::test]
1547 async fn test_handle_diagnostics_pull_error_and_empty_cache_propagates_error() {
1548 let dir = TempDir::new().unwrap();
1549 let mut extensions = HashMap::new();
1550 extensions.insert(
1551 FileExtension::from_static("rs"),
1552 LanguageId::from_static("rust"),
1553 );
1554
1555 let mut translator =
1556 Translator::new()
1557 .with_extensions(extensions)
1558 .with_router(ToolRouter::catch_all([(
1559 ServerId::from_static("rust"),
1560 LanguageId::from_static("rust"),
1561 )]));
1562 translator
1563 .set_workspace_roots(WorkspaceRoots::from_paths(&[dir.path().to_path_buf()]).unwrap());
1564
1565 let (client, mut server) = fake_lsp_client();
1566 translator.register_client(ServerId::from_static("rust"), client);
1567
1568 let path = dir.path().join("lib.rs");
1569 fs::write(&path, "fn main() {}").unwrap();
1570 let path_str = path.to_string_lossy().to_string();
1571
1572 let notification_cache = Mutex::new(NotificationCache::new());
1573
1574 let translator = Arc::new(translator);
1575 let handle = {
1576 let translator = Arc::clone(&translator);
1577 tokio::spawn(async move {
1578 translator
1579 .handle_diagnostics(
1580 client_path(path_str),
1581 ResultContext::None,
1582 ¬ification_cache,
1583 )
1584 .await
1585 })
1586 };
1587
1588 let mut wire = BufReader::new(&mut server.write_stdout);
1589 let opened = read_framed_message(&mut wire).await;
1590 assert_eq!(opened["method"], "textDocument/didOpen");
1591 let diag_request = read_framed_message(&mut wire).await;
1592 assert_eq!(diag_request["method"], "textDocument/diagnostic");
1593 write_error_response(
1594 &mut server.read_half_stdin,
1595 &diag_request["id"],
1596 -32603,
1597 "internal error",
1598 )
1599 .await;
1600
1601 let result = timeout(Duration::from_secs(2), handle)
1602 .await
1603 .expect("handler call should not hang")
1604 .unwrap();
1605
1606 assert!(
1607 result.is_err(),
1608 "pull error with no cache data must propagate, got {result:?}"
1609 );
1610 }
1611}