1use std::path::PathBuf;
5use std::sync::{Arc, LazyLock};
6
7use lsp_types::{
8 DocumentDiagnosticParams, PartialResultParams, TextDocumentIdentifier, WorkDoneProgressParams,
9};
10use tokio::sync::Mutex;
11
12use super::Translator;
13use super::dto::{
14 Diagnostic, DiagnosticSeverity, DiagnosticsResult, Position2D, Range, ServerLogsResult,
15 ServerMessagesResult,
16};
17use super::encoding_ctx::EncodingCtx;
18use super::routing::validate_path_against_roots;
19use crate::bridge::encoding::PositionEncoding;
20use crate::bridge::notifications::message_as_str;
21use crate::bridge::{DiagnosticInfo, DocumentTracker, NotificationCache, path_to_uri};
22use crate::config::ToolKind;
23use crate::error::{Error, Result};
24
25#[derive(serde::Deserialize)]
37#[serde(untagged)]
38enum DocumentDiagnosticReportResult {
39 Report(lsp_types::DocumentDiagnosticReport),
40 Partial(#[allow(dead_code)] lsp_types::DocumentDiagnosticReportPartialResult),
45}
46
47static EMPTY_WORKSPACE_ROOTS: LazyLock<Arc<Vec<PathBuf>>> = LazyLock::new(|| Arc::new(Vec::new()));
50
51pub(super) async fn diagnostic_to_mcp(
58 diag: &lsp_types::Diagnostic,
59 ctx: &EncodingCtx,
60 uri: &lsp_types::Uri,
61) -> Diagnostic {
62 Diagnostic {
63 range: ctx.normalize_range(uri, diag.range).await,
64 severity: match diag.severity {
65 Some(lsp_types::DiagnosticSeverity::Error) => DiagnosticSeverity::Error,
66 Some(lsp_types::DiagnosticSeverity::Warning) => DiagnosticSeverity::Warning,
67 Some(lsp_types::DiagnosticSeverity::Hint) => DiagnosticSeverity::Hint,
68 _ => DiagnosticSeverity::Information,
70 },
71 message: message_as_str(&diag.message).to_string(),
72 code: diag.code.as_ref().map(|c| match c {
73 lsp_types::Code::Int(n) => n.to_string(),
74 lsp_types::Code::String(s) => s.clone(),
75 }),
76 }
77}
78
79impl Translator {
80 pub fn cached_diagnostics_uri(workspace_roots: &[PathBuf], file_path: &str) -> Result<String> {
93 Self::cached_diagnostics_path_and_uri(workspace_roots, file_path).map(|(_, uri)| uri)
94 }
95
96 pub(crate) fn cached_diagnostics_path_and_uri(
107 workspace_roots: &[PathBuf],
108 file_path: &str,
109 ) -> Result<(PathBuf, String)> {
110 let path = PathBuf::from(file_path);
111 let validated_path = validate_path_against_roots(&path, workspace_roots)?;
112
113 let uri = path_to_uri(&validated_path)?.to_string();
116 Ok((validated_path, uri))
117 }
118
119 pub async fn handle_diagnostics(
155 &self,
156 file_path: String,
157 notification_cache: &Mutex<NotificationCache>,
158 ) -> Result<DiagnosticsResult> {
159 let (server_id, client, uri) = self
160 .prepare_document(&file_path, ToolKind::Diagnostics)
161 .await?;
162 let ctx = self.encoding_ctx(&server_id);
163
164 let params = DocumentDiagnosticParams {
165 text_document: TextDocumentIdentifier { uri: uri.clone() },
166 identifier: None,
167 previous_result_id: None,
168 work_done_progress_params: WorkDoneProgressParams::default(),
169 partial_result_params: PartialResultParams::default(),
170 };
171
172 let pull_response: Result<DocumentDiagnosticReportResult> = client
173 .request("textDocument/diagnostic", params, client.request_timeout())
174 .await;
175
176 let diag_info = {
177 let cache = notification_cache.lock().await;
178 cache.diagnostics(uri.as_ref()).cloned()
179 };
180
181 match pull_response {
182 Ok(response) => {
183 let items = match response {
184 DocumentDiagnosticReportResult::Report(report) => match report {
185 lsp_types::DocumentDiagnosticReport::RelatedFullDocumentDiagnosticReport(
186 full,
187 ) => full.full_document_diagnostic_report.items,
188 lsp_types::DocumentDiagnosticReport::RelatedUnchangedDocumentDiagnosticReport(
189 _,
190 ) => vec![],
191 },
192 DocumentDiagnosticReportResult::Partial(_) => vec![],
193 };
194 let mut diagnostics = Vec::with_capacity(items.len());
195 for d in &items {
196 diagnostics.push(diagnostic_to_mcp(d, &ctx, &uri).await);
197 }
198 let pull = DiagnosticsResult {
199 diagnostics,
200 positions_degraded: ctx.positions_degraded(),
201 };
202 Ok(Self::merge_diagnostics(
203 pull,
204 diag_info.as_ref(),
205 ctx.encoding,
206 &self.document_tracker,
207 )
208 .await)
209 }
210 Err(e) => {
211 let cache_only = Self::diagnostics_from_cache_entry(
212 diag_info.as_ref(),
213 ctx.encoding,
214 &self.document_tracker,
215 )
216 .await;
217 if cache_only.diagnostics.is_empty() {
218 Err(e)
219 } else {
220 Ok(cache_only)
221 }
222 }
223 }
224 }
225
226 #[must_use]
237 pub async fn diagnostics_from_cache_entry(
238 diag_info: Option<&DiagnosticInfo>,
239 encoding: PositionEncoding,
240 tracker: &Arc<DocumentTracker>,
241 ) -> DiagnosticsResult {
242 match diag_info {
243 Some(diag_info) => {
244 let ctx = EncodingCtx {
245 encoding,
246 tracker: tracker.clone(),
247 workspace_roots: EMPTY_WORKSPACE_ROOTS.clone(),
249 line_cache: super::encoding_ctx::new_line_cache(),
250 };
251 let mut result = Vec::with_capacity(diag_info.diagnostics.len());
252 for d in &diag_info.diagnostics {
253 result.push(diagnostic_to_mcp(d, &ctx, &diag_info.uri).await);
254 }
255 DiagnosticsResult {
256 diagnostics: result,
257 positions_degraded: ctx.positions_degraded(),
258 }
259 }
260 None => DiagnosticsResult {
261 diagnostics: Vec::new(),
262 positions_degraded: false,
263 },
264 }
265 }
266
267 #[must_use]
303 pub async fn merge_diagnostics(
304 mut pull: DiagnosticsResult,
305 diag_info: Option<&DiagnosticInfo>,
306 encoding: PositionEncoding,
307 tracker: &Arc<DocumentTracker>,
308 ) -> DiagnosticsResult {
309 const DUPLICATE_RANGE_PROXIMITY_LINES: u32 = 3;
317
318 fn position_le(a: &Position2D, b: &Position2D) -> bool {
319 (a.line, a.character) <= (b.line, b.character)
320 }
321
322 fn ranges_close(a: &Range, b: &Range) -> bool {
323 let overlaps = position_le(&a.start, &b.end) && position_le(&b.start, &a.end);
324 overlaps || a.start.line.abs_diff(b.start.line) <= DUPLICATE_RANGE_PROXIMITY_LINES
325 }
326
327 fn is_duplicate(pull: &[Diagnostic], candidate: &Diagnostic) -> bool {
328 pull.iter().any(|p| match (&candidate.code, &p.code) {
329 (Some(c), Some(pc)) if c == pc && p.severity == candidate.severity => {
330 ranges_close(&p.range, &candidate.range)
331 }
332 _ => p == candidate,
333 })
334 }
335
336 let cached = Self::diagnostics_from_cache_entry(diag_info, encoding, tracker).await;
337 pull.positions_degraded |= cached.positions_degraded;
338 let new_diagnostics: Vec<_> = cached
339 .diagnostics
340 .into_iter()
341 .filter(|c| !is_duplicate(&pull.diagnostics, c))
342 .collect();
343 pull.diagnostics.extend(new_diagnostics);
344 pull.diagnostics
345 .sort_by_key(|d| (d.range.start.line, d.range.start.character));
346 pull
347 }
348
349 pub fn handle_server_logs(
355 cache: &NotificationCache,
356 limit: usize,
357 min_level: Option<String>,
358 ) -> Result<ServerLogsResult> {
359 use crate::bridge::notifications::LogLevel;
360
361 let min_level_filter = if let Some(level_str) = min_level {
362 let level = match level_str.to_lowercase().as_str() {
363 "error" => LogLevel::Error,
364 "warning" => LogLevel::Warning,
365 "info" => LogLevel::Info,
366 "debug" => LogLevel::Debug,
367 _ => {
368 return Err(Error::InvalidToolParams(format!(
369 "Invalid min_level: '{level_str}'. Valid values: error, warning, info, debug"
370 )));
371 }
372 };
373 Some(level)
374 } else {
375 None
376 };
377
378 let all_logs = cache.logs();
379
380 let logs: Vec<_> = all_logs
381 .iter()
382 .filter(|log| {
383 min_level_filter.is_none_or(|min| match min {
384 LogLevel::Error => matches!(log.level, LogLevel::Error),
385 LogLevel::Warning => matches!(log.level, LogLevel::Error | LogLevel::Warning),
386 LogLevel::Info => !matches!(log.level, LogLevel::Debug),
387 LogLevel::Debug => true,
388 })
389 })
390 .take(limit)
391 .cloned()
392 .collect();
393
394 Ok(ServerLogsResult { logs })
395 }
396
397 pub fn handle_server_messages(
403 cache: &NotificationCache,
404 limit: usize,
405 ) -> Result<ServerMessagesResult> {
406 let all_messages = cache.messages();
407 let messages: Vec<_> = all_messages.iter().take(limit).cloned().collect();
408 Ok(ServerMessagesResult { messages })
409 }
410}
411
412#[cfg(test)]
413#[allow(clippy::unwrap_used, clippy::expect_used)]
414mod tests {
415 use std::collections::HashMap;
416 use std::fs;
417 use std::sync::Arc;
418 use std::time::Duration;
419
420 use tempfile::TempDir;
421 use tokio::io::BufReader;
422 use tokio::time::timeout;
423 use url::Url;
424
425 use super::*;
426 use crate::bridge::translator::testing::*;
427 use crate::config::{ServerId, ToolRouter};
428
429 #[test]
435 fn test_document_diagnostic_params_omit_optional_null_fields() {
436 let uri = lsp_types::Uri::from("file:///test.ts");
437 let params = DocumentDiagnosticParams {
438 text_document: TextDocumentIdentifier { uri },
439 identifier: None,
440 previous_result_id: None,
441 work_done_progress_params: WorkDoneProgressParams::default(),
442 partial_result_params: PartialResultParams::default(),
443 };
444 let value = serde_json::to_value(params).unwrap();
445
446 assert_eq!(value["textDocument"]["uri"], "file:///test.ts");
447 assert!(value.get("identifier").is_none());
448 assert!(value.get("previousResultId").is_none());
449 }
450
451 #[tokio::test]
452 async fn test_handle_cached_diagnostics_empty() {
453 let cache = NotificationCache::new();
454 let temp_dir = TempDir::new().unwrap();
455 let test_file = temp_dir.path().join("test.rs");
456 fs::write(&test_file, "fn main() {}").unwrap();
457
458 let cache_key = Translator::cached_diagnostics_uri(
459 &[temp_dir.path().to_path_buf()],
460 test_file.to_str().unwrap(),
461 )
462 .unwrap();
463 let diag_info = cache.diagnostics(&cache_key).cloned();
464 let diags = Translator::diagnostics_from_cache_entry(
465 diag_info.as_ref(),
466 PositionEncoding::Utf16,
467 &test_tracker(),
468 )
469 .await;
470 assert_eq!(diags.diagnostics.len(), 0);
471 }
472
473 #[test]
474 fn test_handle_server_logs_with_filter() {
475 use crate::bridge::notifications::LogLevel;
476
477 let mut cache = NotificationCache::new();
478
479 cache.store_log(LogLevel::Error, "error msg".to_string());
481 cache.store_log(LogLevel::Warning, "warning msg".to_string());
482 cache.store_log(LogLevel::Info, "info msg".to_string());
483 cache.store_log(LogLevel::Debug, "debug msg".to_string());
484
485 let result = Translator::handle_server_logs(&cache, 10, Some("error".to_string()));
487 assert!(result.is_ok());
488 let logs = result.unwrap();
489 assert_eq!(logs.logs.len(), 1);
490 assert_eq!(logs.logs[0].message, "error msg");
491
492 let result = Translator::handle_server_logs(&cache, 10, Some("warning".to_string()));
494 assert!(result.is_ok());
495 let logs = result.unwrap();
496 assert_eq!(logs.logs.len(), 2);
497
498 let result = Translator::handle_server_logs(&cache, 10, Some("info".to_string()));
500 assert!(result.is_ok());
501 let logs = result.unwrap();
502 assert_eq!(logs.logs.len(), 3);
503
504 let result = Translator::handle_server_logs(&cache, 10, Some("debug".to_string()));
506 assert!(result.is_ok());
507 let logs = result.unwrap();
508 assert_eq!(logs.logs.len(), 4);
509
510 let result = Translator::handle_server_logs(&cache, 10, Some("invalid".to_string()));
512 assert!(matches!(result, Err(Error::InvalidToolParams(_))));
513 }
514
515 #[test]
516 fn test_handle_server_messages_limit() {
517 use crate::bridge::notifications::MessageType;
518
519 let mut cache = NotificationCache::new();
520
521 for i in 0..10 {
523 cache.store_message(MessageType::Info, format!("message {i}"));
524 }
525
526 let result = Translator::handle_server_messages(&cache, 5);
528 assert!(result.is_ok());
529 let messages = result.unwrap();
530 assert_eq!(messages.messages.len(), 5);
531 assert_eq!(messages.messages[0].message, "message 0");
532 assert_eq!(messages.messages[4].message, "message 4");
533
534 let result = Translator::handle_server_messages(&cache, 100);
536 assert!(result.is_ok());
537 let messages = result.unwrap();
538 assert_eq!(messages.messages.len(), 10);
539 }
540
541 #[tokio::test]
542 async fn test_handle_cached_diagnostics_with_data() {
543 let mut cache = NotificationCache::new();
544 let temp_dir = TempDir::new().unwrap();
545 let test_file = temp_dir.path().join("test.rs");
546 fs::write(&test_file, "fn main() {}").unwrap();
547
548 let canonical_path = test_file.canonicalize().unwrap();
549 let uri: lsp_types::Uri =
550 lsp_types::Uri::from(Url::from_file_path(&canonical_path).unwrap().as_str());
551 let diagnostic = lsp_types::Diagnostic {
552 range: lsp_types::Range {
553 start: lsp_types::Position {
554 line: 0,
555 character: 0,
556 },
557 end: lsp_types::Position {
558 line: 0,
559 character: 5,
560 },
561 },
562 severity: Some(lsp_types::DiagnosticSeverity::Error),
563 message: "test error".to_string().into(),
564 code: Some(lsp_types::Code::String("E001".to_string())),
565 source: None,
566 code_description: None,
567 related_information: None,
568 tags: None,
569 data: None,
570 };
571
572 cache.store_diagnostics(&ServerId::from("rust"), &uri, Some(1), vec![diagnostic]);
573
574 let cache_key = Translator::cached_diagnostics_uri(
575 &[temp_dir.path().to_path_buf()],
576 test_file.to_str().unwrap(),
577 )
578 .unwrap();
579 let diag_info = cache.diagnostics(&cache_key).cloned();
580 let diags = Translator::diagnostics_from_cache_entry(
581 diag_info.as_ref(),
582 PositionEncoding::Utf16,
583 &test_tracker(),
584 )
585 .await;
586 assert_eq!(diags.diagnostics.len(), 1);
587 assert_eq!(diags.diagnostics[0].message, "test error");
588 assert_eq!(diags.diagnostics[0].code, Some("E001".to_string()));
589 assert!(matches!(
590 diags.diagnostics[0].severity,
591 DiagnosticSeverity::Error
592 ));
593 assert_eq!(diags.diagnostics[0].range.start.line, 1);
594 assert_eq!(diags.diagnostics[0].range.start.character, 1);
595 }
596
597 #[tokio::test]
598 #[allow(clippy::too_many_lines)]
599 async fn test_handle_cached_diagnostics_multiple_severities() {
600 let mut cache = NotificationCache::new();
601 let temp_dir = TempDir::new().unwrap();
602 let test_file = temp_dir.path().join("test.rs");
603 fs::write(&test_file, "fn main() {}").unwrap();
604
605 let canonical_path = test_file.canonicalize().unwrap();
606 let uri: lsp_types::Uri =
607 lsp_types::Uri::from(Url::from_file_path(&canonical_path).unwrap().as_str());
608 let diagnostics = vec![
609 lsp_types::Diagnostic {
610 range: lsp_types::Range {
611 start: lsp_types::Position {
612 line: 0,
613 character: 0,
614 },
615 end: lsp_types::Position {
616 line: 0,
617 character: 5,
618 },
619 },
620 severity: Some(lsp_types::DiagnosticSeverity::Error),
621 message: "error".to_string().into(),
622 code: None,
623 source: None,
624 code_description: None,
625 related_information: None,
626 tags: None,
627 data: None,
628 },
629 lsp_types::Diagnostic {
630 range: lsp_types::Range {
631 start: lsp_types::Position {
632 line: 1,
633 character: 0,
634 },
635 end: lsp_types::Position {
636 line: 1,
637 character: 5,
638 },
639 },
640 severity: Some(lsp_types::DiagnosticSeverity::Warning),
641 message: "warning".to_string().into(),
642 code: None,
643 source: None,
644 code_description: None,
645 related_information: None,
646 tags: None,
647 data: None,
648 },
649 lsp_types::Diagnostic {
650 range: lsp_types::Range {
651 start: lsp_types::Position {
652 line: 2,
653 character: 0,
654 },
655 end: lsp_types::Position {
656 line: 2,
657 character: 5,
658 },
659 },
660 severity: Some(lsp_types::DiagnosticSeverity::Information),
661 message: "info".to_string().into(),
662 code: None,
663 source: None,
664 code_description: None,
665 related_information: None,
666 tags: None,
667 data: None,
668 },
669 lsp_types::Diagnostic {
670 range: lsp_types::Range {
671 start: lsp_types::Position {
672 line: 3,
673 character: 0,
674 },
675 end: lsp_types::Position {
676 line: 3,
677 character: 5,
678 },
679 },
680 severity: Some(lsp_types::DiagnosticSeverity::Hint),
681 message: "hint".to_string().into(),
682 code: None,
683 source: None,
684 code_description: None,
685 related_information: None,
686 tags: None,
687 data: None,
688 },
689 ];
690
691 cache.store_diagnostics(&ServerId::from("rust"), &uri, Some(1), diagnostics);
692
693 let cache_key = Translator::cached_diagnostics_uri(
694 &[temp_dir.path().to_path_buf()],
695 test_file.to_str().unwrap(),
696 )
697 .unwrap();
698 let diag_info = cache.diagnostics(&cache_key).cloned();
699 let diags = Translator::diagnostics_from_cache_entry(
700 diag_info.as_ref(),
701 PositionEncoding::Utf16,
702 &test_tracker(),
703 )
704 .await;
705 assert_eq!(diags.diagnostics.len(), 4);
706 assert!(matches!(
707 diags.diagnostics[0].severity,
708 DiagnosticSeverity::Error
709 ));
710 assert!(matches!(
711 diags.diagnostics[1].severity,
712 DiagnosticSeverity::Warning
713 ));
714 assert!(matches!(
715 diags.diagnostics[2].severity,
716 DiagnosticSeverity::Information
717 ));
718 assert!(matches!(
719 diags.diagnostics[3].severity,
720 DiagnosticSeverity::Hint
721 ));
722 }
723
724 #[tokio::test]
725 async fn test_handle_cached_diagnostics_with_numeric_code() {
726 let mut cache = NotificationCache::new();
727 let temp_dir = TempDir::new().unwrap();
728 let test_file = temp_dir.path().join("test.rs");
729 fs::write(&test_file, "fn main() {}").unwrap();
730
731 let canonical_path = test_file.canonicalize().unwrap();
732 let uri: lsp_types::Uri =
733 lsp_types::Uri::from(Url::from_file_path(&canonical_path).unwrap().as_str());
734 let diagnostic = lsp_types::Diagnostic {
735 range: lsp_types::Range {
736 start: lsp_types::Position {
737 line: 0,
738 character: 0,
739 },
740 end: lsp_types::Position {
741 line: 0,
742 character: 5,
743 },
744 },
745 severity: Some(lsp_types::DiagnosticSeverity::Error),
746 message: "test error".to_string().into(),
747 code: Some(lsp_types::Code::Int(42)),
748 source: None,
749 code_description: None,
750 related_information: None,
751 tags: None,
752 data: None,
753 };
754
755 cache.store_diagnostics(&ServerId::from("rust"), &uri, Some(1), vec![diagnostic]);
756
757 let cache_key = Translator::cached_diagnostics_uri(
758 &[temp_dir.path().to_path_buf()],
759 test_file.to_str().unwrap(),
760 )
761 .unwrap();
762 let diag_info = cache.diagnostics(&cache_key).cloned();
763 let diags = Translator::diagnostics_from_cache_entry(
764 diag_info.as_ref(),
765 PositionEncoding::Utf16,
766 &test_tracker(),
767 )
768 .await;
769 assert_eq!(diags.diagnostics.len(), 1);
770 assert_eq!(diags.diagnostics[0].code, Some("42".to_string()));
771 }
772
773 #[test]
774 fn test_handle_cached_diagnostics_invalid_path() {
775 #[cfg(windows)]
776 let root = PathBuf::from(r"C:\");
777 #[cfg(not(windows))]
778 let root = PathBuf::from("/");
779 let result = Translator::cached_diagnostics_uri(&[root], "/nonexistent/path/file.rs");
780 assert!(matches!(result, Err(Error::FileIo { .. })));
781 }
782
783 #[tokio::test]
784 async fn test_merge_diagnostics_cache_only_appends_to_empty_pull() {
785 let pull = DiagnosticsResult {
786 diagnostics: vec![],
787 positions_degraded: false,
788 };
789 let cache = diag_info(vec![lsp_diag(
790 0,
791 10,
792 lsp_types::DiagnosticSeverity::Warning,
793 "unused import: `std::fmt`",
794 None,
795 )]);
796
797 let merged = Translator::merge_diagnostics(
798 pull,
799 Some(&cache),
800 PositionEncoding::Utf16,
801 &test_tracker(),
802 )
803 .await;
804
805 assert_eq!(merged.diagnostics.len(), 1);
806 assert_eq!(merged.diagnostics[0].message, "unused import: `std::fmt`");
807 assert!(matches!(
808 merged.diagnostics[0].severity,
809 DiagnosticSeverity::Warning
810 ));
811 }
812
813 #[tokio::test]
814 async fn test_merge_diagnostics_exact_duplicate_not_repeated() {
815 let pull_diag = Diagnostic {
818 range: Range {
819 start: Position2D {
820 line: 1,
821 character: 1,
822 },
823 end: Position2D {
824 line: 1,
825 character: 11,
826 },
827 },
828 severity: DiagnosticSeverity::Error,
829 message: "mismatched types".to_string(),
830 code: Some("E0308".to_string()),
831 };
832 let pull = DiagnosticsResult {
833 diagnostics: vec![pull_diag.clone()],
834 positions_degraded: false,
835 };
836 let cache = diag_info(vec![lsp_diag(
837 0,
838 10,
839 lsp_types::DiagnosticSeverity::Error,
840 "mismatched types",
841 Some("E0308"),
842 )]);
843
844 let merged = Translator::merge_diagnostics(
845 pull,
846 Some(&cache),
847 PositionEncoding::Utf16,
848 &test_tracker(),
849 )
850 .await;
851
852 assert_eq!(merged.diagnostics.len(), 1);
853 assert_eq!(merged.diagnostics[0], pull_diag);
854 }
855
856 #[tokio::test]
861 async fn test_merge_diagnostics_positions_degraded_true_when_pull_side_is_degraded() {
862 let pull = DiagnosticsResult {
863 diagnostics: vec![],
864 positions_degraded: true,
865 };
866 let cache = diag_info(vec![lsp_diag(
867 0,
868 10,
869 lsp_types::DiagnosticSeverity::Warning,
870 "unused import: `std::fmt`",
871 None,
872 )]);
873
874 let merged = Translator::merge_diagnostics(
875 pull,
876 Some(&cache),
877 PositionEncoding::Utf16,
878 &test_tracker(),
879 )
880 .await;
881
882 assert!(
883 merged.positions_degraded,
884 "the pull side's degraded flag must survive the merge even when the cache side \
885 isn't degraded"
886 );
887 }
888
889 #[tokio::test]
895 async fn test_merge_diagnostics_positions_degraded_true_when_cache_side_is_degraded() {
896 let pull = DiagnosticsResult {
897 diagnostics: vec![],
898 positions_degraded: false,
899 };
900 let cache = diag_info(vec![lsp_diag(
901 0,
902 10,
903 lsp_types::DiagnosticSeverity::Warning,
904 "unused import: `std::fmt`",
905 None,
906 )]);
907
908 let merged = Translator::merge_diagnostics(
909 pull,
910 Some(&cache),
911 PositionEncoding::Utf8,
912 &test_tracker(),
913 )
914 .await;
915
916 assert!(
917 merged.positions_degraded,
918 "the cache side's degraded flag must be OR-ed into the merged result even when the \
919 pull side isn't degraded"
920 );
921 }
922
923 #[tokio::test]
924 async fn test_merge_diagnostics_no_cache_entry_returns_pull_unchanged() {
925 let pull_diag = Diagnostic {
926 range: Range {
927 start: Position2D {
928 line: 1,
929 character: 1,
930 },
931 end: Position2D {
932 line: 1,
933 character: 5,
934 },
935 },
936 severity: DiagnosticSeverity::Error,
937 message: "syntax error".to_string(),
938 code: None,
939 };
940 let pull = DiagnosticsResult {
941 diagnostics: vec![pull_diag.clone()],
942 positions_degraded: false,
943 };
944
945 let merged =
946 Translator::merge_diagnostics(pull, None, PositionEncoding::Utf16, &test_tracker())
947 .await;
948
949 assert_eq!(merged.diagnostics, vec![pull_diag]);
950 }
951
952 #[tokio::test]
953 async fn test_merge_diagnostics_multiple_distinct_cache_entries_all_appear() {
954 let pull = DiagnosticsResult {
955 diagnostics: vec![],
956 positions_degraded: false,
957 };
958 let cache = diag_info(vec![
959 lsp_diag(
960 0,
961 10,
962 lsp_types::DiagnosticSeverity::Warning,
963 "unused import: `std::fmt`",
964 None,
965 ),
966 lsp_diag(
967 5,
968 8,
969 lsp_types::DiagnosticSeverity::Warning,
970 "function `helper` is never used",
971 None,
972 ),
973 ]);
974
975 let merged = Translator::merge_diagnostics(
976 pull,
977 Some(&cache),
978 PositionEncoding::Utf16,
979 &test_tracker(),
980 )
981 .await;
982
983 assert_eq!(merged.diagnostics.len(), 2);
984 assert!(
985 merged
986 .diagnostics
987 .iter()
988 .any(|d| d.message == "unused import: `std::fmt`")
989 );
990 assert!(
991 merged
992 .diagnostics
993 .iter()
994 .any(|d| d.message == "function `helper` is never used")
995 );
996 }
997
998 #[tokio::test]
999 async fn test_merge_diagnostics_same_range_different_message_not_deduped() {
1000 let pull_diag = Diagnostic {
1001 range: Range {
1002 start: Position2D {
1003 line: 1,
1004 character: 1,
1005 },
1006 end: Position2D {
1007 line: 1,
1008 character: 11,
1009 },
1010 },
1011 severity: DiagnosticSeverity::Error,
1012 message: "mismatched types".to_string(),
1013 code: None,
1014 };
1015 let pull = DiagnosticsResult {
1016 diagnostics: vec![pull_diag],
1017 positions_degraded: false,
1018 };
1019 let cache = diag_info(vec![lsp_diag(
1022 0,
1023 10,
1024 lsp_types::DiagnosticSeverity::Error,
1025 "expected `i32`, found `&str`",
1026 None,
1027 )]);
1028
1029 let merged = Translator::merge_diagnostics(
1030 pull,
1031 Some(&cache),
1032 PositionEncoding::Utf16,
1033 &test_tracker(),
1034 )
1035 .await;
1036
1037 assert_eq!(merged.diagnostics.len(), 2);
1038 }
1039
1040 #[tokio::test]
1048 async fn test_merge_diagnostics_same_code_different_range_and_message_deduped() {
1049 let pull_diag = Diagnostic {
1050 range: Range {
1051 start: Position2D {
1052 line: 96,
1053 character: 7,
1054 },
1055 end: Position2D {
1056 line: 96,
1057 character: 12,
1058 },
1059 },
1060 severity: DiagnosticSeverity::Error,
1061 message: "not all trait items implemented, missing: `fn hello`".to_string(),
1062 code: Some("E0046".to_string()),
1063 };
1064 let pull = DiagnosticsResult {
1065 diagnostics: vec![pull_diag.clone()],
1066 positions_degraded: false,
1067 };
1068 let cache = diag_info(vec![lsp_diag(
1072 94,
1073 31,
1074 lsp_types::DiagnosticSeverity::Error,
1075 "not all trait items implemented, missing: `hello`\nmissing `hello` in implementation",
1076 Some("E0046"),
1077 )]);
1078
1079 let merged = Translator::merge_diagnostics(
1080 pull,
1081 Some(&cache),
1082 PositionEncoding::Utf16,
1083 &test_tracker(),
1084 )
1085 .await;
1086
1087 assert_eq!(merged.diagnostics.len(), 1);
1088 assert_eq!(merged.diagnostics[0], pull_diag);
1089 }
1090
1091 #[tokio::test]
1106 async fn test_merge_diagnostics_same_code_distinct_diagnostics_at_different_locations_both_kept()
1107 {
1108 let pull_diag = Diagnostic {
1109 range: Range {
1110 start: Position2D {
1111 line: 5,
1112 character: 9,
1113 },
1114 end: Position2D {
1115 line: 5,
1116 character: 20,
1117 },
1118 },
1119 severity: DiagnosticSeverity::Error,
1120 message: "mismatched types: expected `i32`, found `&str`".to_string(),
1121 code: Some("E0308".to_string()),
1122 };
1123 let pull = DiagnosticsResult {
1124 diagnostics: vec![pull_diag.clone()],
1125 positions_degraded: false,
1126 };
1127 let cache = diag_info(vec![lsp_diag(
1131 49,
1132 22,
1133 lsp_types::DiagnosticSeverity::Error,
1134 "mismatched types: expected `String`, found `Vec<u8>`",
1135 Some("E0308"),
1136 )]);
1137
1138 let merged = Translator::merge_diagnostics(
1139 pull,
1140 Some(&cache),
1141 PositionEncoding::Utf16,
1142 &test_tracker(),
1143 )
1144 .await;
1145
1146 assert_eq!(merged.diagnostics.len(), 2);
1147 assert_eq!(merged.diagnostics[0], pull_diag);
1148 assert_eq!(
1149 merged.diagnostics[1].message,
1150 "mismatched types: expected `String`, found `Vec<u8>`"
1151 );
1152 }
1153
1154 #[test]
1155 fn test_handle_server_logs_no_filter() {
1156 use crate::bridge::notifications::LogLevel;
1157
1158 let mut cache = NotificationCache::new();
1159
1160 cache.store_log(LogLevel::Error, "error msg".to_string());
1161 cache.store_log(LogLevel::Warning, "warning msg".to_string());
1162 cache.store_log(LogLevel::Info, "info msg".to_string());
1163 cache.store_log(LogLevel::Debug, "debug msg".to_string());
1164
1165 let result = Translator::handle_server_logs(&cache, 10, None);
1166 assert!(result.is_ok());
1167 let logs = result.unwrap();
1168 assert_eq!(logs.logs.len(), 4);
1169 }
1170
1171 #[test]
1172 fn test_handle_server_logs_error_filter_strict() {
1173 use crate::bridge::notifications::LogLevel;
1174
1175 let mut cache = NotificationCache::new();
1176
1177 cache.store_log(LogLevel::Error, "error msg".to_string());
1178 cache.store_log(LogLevel::Warning, "warning msg".to_string());
1179 cache.store_log(LogLevel::Info, "info msg".to_string());
1180
1181 let result = Translator::handle_server_logs(&cache, 10, Some("error".to_string()));
1182 assert!(result.is_ok());
1183 let logs = result.unwrap();
1184 assert_eq!(logs.logs.len(), 1);
1185 assert_eq!(logs.logs[0].message, "error msg");
1186 }
1187
1188 #[test]
1189 fn test_handle_server_logs_warning_filter_includes_errors() {
1190 use crate::bridge::notifications::LogLevel;
1191
1192 let mut cache = NotificationCache::new();
1193
1194 cache.store_log(LogLevel::Error, "error msg".to_string());
1195 cache.store_log(LogLevel::Warning, "warning msg".to_string());
1196 cache.store_log(LogLevel::Info, "info msg".to_string());
1197
1198 let result = Translator::handle_server_logs(&cache, 10, Some("warning".to_string()));
1199 assert!(result.is_ok());
1200 let logs = result.unwrap();
1201 assert_eq!(logs.logs.len(), 2);
1202 }
1203
1204 #[test]
1205 fn test_handle_server_logs_info_filter_excludes_debug() {
1206 use crate::bridge::notifications::LogLevel;
1207
1208 let mut cache = NotificationCache::new();
1209
1210 cache.store_log(LogLevel::Error, "error msg".to_string());
1211 cache.store_log(LogLevel::Info, "info msg".to_string());
1212 cache.store_log(LogLevel::Debug, "debug msg".to_string());
1213
1214 let result = Translator::handle_server_logs(&cache, 10, Some("info".to_string()));
1215 assert!(result.is_ok());
1216 let logs = result.unwrap();
1217 assert_eq!(logs.logs.len(), 2);
1218 }
1219
1220 #[test]
1221 fn test_handle_server_logs_debug_filter_includes_all() {
1222 use crate::bridge::notifications::LogLevel;
1223
1224 let mut cache = NotificationCache::new();
1225
1226 cache.store_log(LogLevel::Error, "error msg".to_string());
1227 cache.store_log(LogLevel::Warning, "warning msg".to_string());
1228 cache.store_log(LogLevel::Info, "info msg".to_string());
1229 cache.store_log(LogLevel::Debug, "debug msg".to_string());
1230
1231 let result = Translator::handle_server_logs(&cache, 10, Some("debug".to_string()));
1232 assert!(result.is_ok());
1233 let logs = result.unwrap();
1234 assert_eq!(logs.logs.len(), 4);
1235 }
1236
1237 #[test]
1238 fn test_handle_server_logs_limit_applies_after_filter() {
1239 use crate::bridge::notifications::LogLevel;
1240
1241 let mut cache = NotificationCache::new();
1242
1243 for i in 0..10 {
1244 cache.store_log(LogLevel::Error, format!("error {i}"));
1245 }
1246
1247 let result = Translator::handle_server_logs(&cache, 5, Some("error".to_string()));
1248 assert!(result.is_ok());
1249 let logs = result.unwrap();
1250 assert_eq!(logs.logs.len(), 5);
1251 assert_eq!(logs.logs[0].message, "error 0");
1252 assert_eq!(logs.logs[4].message, "error 4");
1253 }
1254
1255 #[test]
1256 fn test_handle_server_logs_case_insensitive_level() {
1257 use crate::bridge::notifications::LogLevel;
1258
1259 let mut cache = NotificationCache::new();
1260
1261 cache.store_log(LogLevel::Error, "error msg".to_string());
1262
1263 let result = Translator::handle_server_logs(&cache, 10, Some("ERROR".to_string()));
1264 assert!(result.is_ok());
1265
1266 let result = Translator::handle_server_logs(&cache, 10, Some("Error".to_string()));
1267 assert!(result.is_ok());
1268
1269 let result = Translator::handle_server_logs(&cache, 10, Some("eRrOr".to_string()));
1270 assert!(result.is_ok());
1271 }
1272
1273 #[test]
1274 fn test_handle_server_messages_empty() {
1275 let cache = NotificationCache::new();
1276
1277 let result = Translator::handle_server_messages(&cache, 10);
1278 assert!(result.is_ok());
1279 let messages = result.unwrap();
1280 assert_eq!(messages.messages.len(), 0);
1281 }
1282
1283 #[test]
1284 fn test_handle_server_messages_with_different_types() {
1285 use crate::bridge::notifications::MessageType;
1286
1287 let mut cache = NotificationCache::new();
1288
1289 cache.store_message(MessageType::Error, "error".to_string());
1290 cache.store_message(MessageType::Warning, "warning".to_string());
1291 cache.store_message(MessageType::Info, "info".to_string());
1292 cache.store_message(MessageType::Log, "log".to_string());
1293
1294 let result = Translator::handle_server_messages(&cache, 10);
1295 assert!(result.is_ok());
1296 let messages = result.unwrap();
1297 assert_eq!(messages.messages.len(), 4);
1298 assert_eq!(messages.messages[0].message, "error");
1299 assert_eq!(messages.messages[1].message, "warning");
1300 assert_eq!(messages.messages[2].message, "info");
1301 assert_eq!(messages.messages[3].message, "log");
1302 }
1303
1304 #[test]
1305 fn test_handle_server_messages_zero_limit() {
1306 use crate::bridge::notifications::MessageType;
1307
1308 let mut cache = NotificationCache::new();
1309
1310 cache.store_message(MessageType::Info, "test".to_string());
1311
1312 let result = Translator::handle_server_messages(&cache, 0);
1313 assert!(result.is_ok());
1314 let messages = result.unwrap();
1315 assert_eq!(messages.messages.len(), 0);
1316 }
1317
1318 #[test]
1319 fn test_handle_cached_diagnostics_path_outside_workspace() {
1320 let temp_dir1 = TempDir::new().unwrap();
1321 let temp_dir2 = TempDir::new().unwrap();
1322
1323 let workspace_roots = vec![temp_dir1.path().to_path_buf()];
1324
1325 let test_file = temp_dir2.path().join("test.rs");
1326 fs::write(&test_file, "fn main() {}").unwrap();
1327
1328 let result =
1329 Translator::cached_diagnostics_uri(&workspace_roots, test_file.to_str().unwrap());
1330 assert!(matches!(result, Err(Error::PathOutsideWorkspace(_))));
1331 }
1332
1333 #[tokio::test]
1338 async fn test_handle_diagnostics_pull_error_falls_back_to_nonempty_cache() {
1339 let dir = TempDir::new().unwrap();
1340 let mut extensions = HashMap::new();
1341 extensions.insert("rs".to_string(), "rust".to_string());
1342
1343 let mut translator =
1344 Translator::new()
1345 .with_extensions(extensions)
1346 .with_router(ToolRouter::catch_all([(
1347 ServerId::from("rust"),
1348 "rust".to_string(),
1349 )]));
1350 translator.set_workspace_roots(vec![dir.path().to_path_buf()]);
1351
1352 let (client, mut server) = fake_lsp_client();
1353 translator.register_client("rust".to_string(), client);
1354
1355 let path = dir.path().join("lib.rs");
1356 fs::write(&path, "fn main() {}").unwrap();
1357 let path_str = path.to_string_lossy().to_string();
1358
1359 let canonical = path.canonicalize().unwrap();
1363 let uri = path_to_uri(&canonical).unwrap();
1364 let notification_cache = Mutex::new(NotificationCache::new());
1365 {
1366 let mut cache = notification_cache.lock().await;
1367 cache.store_diagnostics(
1368 &ServerId::from("rust"),
1369 &uri,
1370 Some(1),
1371 vec![lsp_diag(
1372 0,
1373 4,
1374 lsp_types::DiagnosticSeverity::Warning,
1375 "unused import: `std::fmt`",
1376 None,
1377 )],
1378 );
1379 }
1380
1381 let translator = Arc::new(translator);
1382 let handle = {
1383 let translator = Arc::clone(&translator);
1384 tokio::spawn(async move {
1385 translator
1386 .handle_diagnostics(path_str, ¬ification_cache)
1387 .await
1388 })
1389 };
1390
1391 let mut wire = BufReader::new(&mut server.write_stdout);
1392 let opened = read_framed_message(&mut wire).await;
1393 assert_eq!(opened["method"], "textDocument/didOpen");
1394 let diag_request = read_framed_message(&mut wire).await;
1395 assert_eq!(diag_request["method"], "textDocument/diagnostic");
1396 write_error_response(
1397 &mut server.read_half_stdin,
1398 &diag_request["id"],
1399 -32601,
1400 "method not found",
1401 )
1402 .await;
1403
1404 let result = timeout(Duration::from_secs(2), handle)
1405 .await
1406 .expect("handler call should not hang")
1407 .unwrap();
1408
1409 let diagnostics = result.expect("cache-only fallback should succeed despite pull error");
1410 assert_eq!(diagnostics.diagnostics.len(), 1);
1411 assert_eq!(
1412 diagnostics.diagnostics[0].message,
1413 "unused import: `std::fmt`"
1414 );
1415 }
1416
1417 #[tokio::test]
1426 async fn test_handle_diagnostics_partial_response_degrades_to_empty_result() {
1427 let dir = TempDir::new().unwrap();
1428 let mut extensions = HashMap::new();
1429 extensions.insert("rs".to_string(), "rust".to_string());
1430
1431 let mut translator =
1432 Translator::new()
1433 .with_extensions(extensions)
1434 .with_router(ToolRouter::catch_all([(
1435 ServerId::from("rust"),
1436 "rust".to_string(),
1437 )]));
1438 translator.set_workspace_roots(vec![dir.path().to_path_buf()]);
1439
1440 let (client, mut server) = fake_lsp_client();
1441 translator.register_client("rust".to_string(), client);
1442
1443 let path = dir.path().join("lib.rs");
1444 fs::write(&path, "fn main() {}").unwrap();
1445 let path_str = path.to_string_lossy().to_string();
1446
1447 let notification_cache = Mutex::new(NotificationCache::new());
1448
1449 let translator = Arc::new(translator);
1450 let handle = {
1451 let translator = Arc::clone(&translator);
1452 tokio::spawn(async move {
1453 translator
1454 .handle_diagnostics(path_str, ¬ification_cache)
1455 .await
1456 })
1457 };
1458
1459 let mut wire = BufReader::new(&mut server.write_stdout);
1460 let opened = read_framed_message(&mut wire).await;
1461 assert_eq!(opened["method"], "textDocument/didOpen");
1462 let diag_request = read_framed_message(&mut wire).await;
1463 assert_eq!(diag_request["method"], "textDocument/diagnostic");
1464
1465 write_response(
1468 &mut server.read_half_stdin,
1469 &diag_request["id"],
1470 serde_json::json!({ "relatedDocuments": {} }),
1471 )
1472 .await;
1473
1474 let result = timeout(Duration::from_secs(2), handle)
1475 .await
1476 .expect("handler call should not hang")
1477 .unwrap();
1478
1479 let diagnostics =
1480 result.expect("a Partial response must degrade to Ok(empty), not an error");
1481 assert!(
1482 diagnostics.diagnostics.is_empty(),
1483 "expected no diagnostics from a Partial response, got {diagnostics:?}"
1484 );
1485 }
1486
1487 #[tokio::test]
1490 async fn test_handle_diagnostics_pull_error_and_empty_cache_propagates_error() {
1491 let dir = TempDir::new().unwrap();
1492 let mut extensions = HashMap::new();
1493 extensions.insert("rs".to_string(), "rust".to_string());
1494
1495 let mut translator =
1496 Translator::new()
1497 .with_extensions(extensions)
1498 .with_router(ToolRouter::catch_all([(
1499 ServerId::from("rust"),
1500 "rust".to_string(),
1501 )]));
1502 translator.set_workspace_roots(vec![dir.path().to_path_buf()]);
1503
1504 let (client, mut server) = fake_lsp_client();
1505 translator.register_client("rust".to_string(), client);
1506
1507 let path = dir.path().join("lib.rs");
1508 fs::write(&path, "fn main() {}").unwrap();
1509 let path_str = path.to_string_lossy().to_string();
1510
1511 let notification_cache = Mutex::new(NotificationCache::new());
1512
1513 let translator = Arc::new(translator);
1514 let handle = {
1515 let translator = Arc::clone(&translator);
1516 tokio::spawn(async move {
1517 translator
1518 .handle_diagnostics(path_str, ¬ification_cache)
1519 .await
1520 })
1521 };
1522
1523 let mut wire = BufReader::new(&mut server.write_stdout);
1524 let opened = read_framed_message(&mut wire).await;
1525 assert_eq!(opened["method"], "textDocument/didOpen");
1526 let diag_request = read_framed_message(&mut wire).await;
1527 assert_eq!(diag_request["method"], "textDocument/diagnostic");
1528 write_error_response(
1529 &mut server.read_half_stdin,
1530 &diag_request["id"],
1531 -32601,
1532 "method not found",
1533 )
1534 .await;
1535
1536 let result = timeout(Duration::from_secs(2), handle)
1537 .await
1538 .expect("handler call should not hang")
1539 .unwrap();
1540
1541 assert!(
1542 result.is_err(),
1543 "pull error with no cache data must propagate, got {result:?}"
1544 );
1545 }
1546}