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