1use std::path::PathBuf;
5use std::sync::Arc;
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
47pub(super) async fn diagnostic_to_mcp(
54 diag: &lsp_types::Diagnostic,
55 ctx: &EncodingCtx,
56 uri: &lsp_types::Uri,
57) -> Diagnostic {
58 Diagnostic {
59 range: ctx.normalize_range(uri, diag.range).await,
60 severity: match diag.severity {
61 Some(lsp_types::DiagnosticSeverity::Error) => DiagnosticSeverity::Error,
62 Some(lsp_types::DiagnosticSeverity::Warning) => DiagnosticSeverity::Warning,
63 Some(lsp_types::DiagnosticSeverity::Hint) => DiagnosticSeverity::Hint,
64 _ => DiagnosticSeverity::Information,
66 },
67 message: message_as_str(&diag.message).to_string(),
68 code: diag.code.as_ref().map(|c| match c {
69 lsp_types::Code::Int(n) => n.to_string(),
70 lsp_types::Code::String(s) => s.clone(),
71 }),
72 }
73}
74
75impl Translator {
76 pub fn cached_diagnostics_uri(workspace_roots: &[PathBuf], file_path: &str) -> Result<String> {
89 let path = PathBuf::from(file_path);
90 let validated_path = validate_path_against_roots(&path, workspace_roots)?;
91
92 Ok(path_to_uri(&validated_path)?.to_string())
95 }
96
97 pub async fn handle_diagnostics(
124 &self,
125 file_path: String,
126 notification_cache: &Mutex<NotificationCache>,
127 ) -> Result<DiagnosticsResult> {
128 let (server_id, client, uri) = self
129 .prepare_document(&file_path, ToolKind::Diagnostics)
130 .await?;
131 let ctx = self.encoding_ctx(&server_id);
132
133 let params = DocumentDiagnosticParams {
134 text_document: TextDocumentIdentifier { uri: uri.clone() },
135 identifier: None,
136 previous_result_id: None,
137 work_done_progress_params: WorkDoneProgressParams::default(),
138 partial_result_params: PartialResultParams::default(),
139 };
140
141 let pull_response: Result<DocumentDiagnosticReportResult> = client
142 .request("textDocument/diagnostic", params, client.request_timeout())
143 .await;
144
145 let diag_info = {
146 let cache = notification_cache.lock().await;
147 cache.diagnostics(uri.as_ref()).cloned()
148 };
149
150 match pull_response {
151 Ok(response) => {
152 let items = match response {
153 DocumentDiagnosticReportResult::Report(report) => match report {
154 lsp_types::DocumentDiagnosticReport::RelatedFullDocumentDiagnosticReport(
155 full,
156 ) => full.full_document_diagnostic_report.items,
157 lsp_types::DocumentDiagnosticReport::RelatedUnchangedDocumentDiagnosticReport(
158 _,
159 ) => vec![],
160 },
161 DocumentDiagnosticReportResult::Partial(_) => vec![],
162 };
163 let mut diagnostics = Vec::with_capacity(items.len());
164 for d in &items {
165 diagnostics.push(diagnostic_to_mcp(d, &ctx, &uri).await);
166 }
167 let pull = DiagnosticsResult { diagnostics };
168 Ok(Self::merge_diagnostics(
169 pull,
170 diag_info.as_ref(),
171 ctx.encoding,
172 &self.document_tracker,
173 )
174 .await)
175 }
176 Err(e) => {
177 let cache_only = Self::diagnostics_from_cache_entry(
178 diag_info.as_ref(),
179 ctx.encoding,
180 &self.document_tracker,
181 )
182 .await;
183 if cache_only.diagnostics.is_empty() {
184 Err(e)
185 } else {
186 Ok(cache_only)
187 }
188 }
189 }
190 }
191
192 #[must_use]
203 pub async fn diagnostics_from_cache_entry(
204 diag_info: Option<&DiagnosticInfo>,
205 encoding: PositionEncoding,
206 tracker: &Arc<DocumentTracker>,
207 ) -> DiagnosticsResult {
208 let diagnostics = match diag_info {
209 Some(diag_info) => {
210 let ctx = EncodingCtx {
211 encoding,
212 tracker: tracker.clone(),
213 };
214 let mut result = Vec::with_capacity(diag_info.diagnostics.len());
215 for d in &diag_info.diagnostics {
216 result.push(diagnostic_to_mcp(d, &ctx, &diag_info.uri).await);
217 }
218 result
219 }
220 None => Vec::new(),
221 };
222
223 DiagnosticsResult { diagnostics }
224 }
225
226 #[must_use]
262 pub async fn merge_diagnostics(
263 mut pull: DiagnosticsResult,
264 diag_info: Option<&DiagnosticInfo>,
265 encoding: PositionEncoding,
266 tracker: &Arc<DocumentTracker>,
267 ) -> DiagnosticsResult {
268 const DUPLICATE_RANGE_PROXIMITY_LINES: u32 = 3;
276
277 fn position_le(a: &Position2D, b: &Position2D) -> bool {
278 (a.line, a.character) <= (b.line, b.character)
279 }
280
281 fn ranges_close(a: &Range, b: &Range) -> bool {
282 let overlaps = position_le(&a.start, &b.end) && position_le(&b.start, &a.end);
283 overlaps || a.start.line.abs_diff(b.start.line) <= DUPLICATE_RANGE_PROXIMITY_LINES
284 }
285
286 fn is_duplicate(pull: &[Diagnostic], candidate: &Diagnostic) -> bool {
287 pull.iter().any(|p| match (&candidate.code, &p.code) {
288 (Some(c), Some(pc)) if c == pc && p.severity == candidate.severity => {
289 ranges_close(&p.range, &candidate.range)
290 }
291 _ => p == candidate,
292 })
293 }
294
295 let cached = Self::diagnostics_from_cache_entry(diag_info, encoding, tracker)
296 .await
297 .diagnostics;
298 let new_diagnostics: Vec<_> = cached
299 .into_iter()
300 .filter(|c| !is_duplicate(&pull.diagnostics, c))
301 .collect();
302 pull.diagnostics.extend(new_diagnostics);
303 pull.diagnostics
304 .sort_by_key(|d| (d.range.start.line, d.range.start.character));
305 pull
306 }
307
308 pub fn handle_server_logs(
314 cache: &NotificationCache,
315 limit: usize,
316 min_level: Option<String>,
317 ) -> Result<ServerLogsResult> {
318 use crate::bridge::notifications::LogLevel;
319
320 let min_level_filter = if let Some(level_str) = min_level {
321 let level = match level_str.to_lowercase().as_str() {
322 "error" => LogLevel::Error,
323 "warning" => LogLevel::Warning,
324 "info" => LogLevel::Info,
325 "debug" => LogLevel::Debug,
326 _ => {
327 return Err(Error::InvalidToolParams(format!(
328 "Invalid min_level: '{level_str}'. Valid values: error, warning, info, debug"
329 )));
330 }
331 };
332 Some(level)
333 } else {
334 None
335 };
336
337 let all_logs = cache.logs();
338
339 let logs: Vec<_> = all_logs
340 .iter()
341 .filter(|log| {
342 min_level_filter.is_none_or(|min| match min {
343 LogLevel::Error => matches!(log.level, LogLevel::Error),
344 LogLevel::Warning => matches!(log.level, LogLevel::Error | LogLevel::Warning),
345 LogLevel::Info => !matches!(log.level, LogLevel::Debug),
346 LogLevel::Debug => true,
347 })
348 })
349 .take(limit)
350 .cloned()
351 .collect();
352
353 Ok(ServerLogsResult { logs })
354 }
355
356 pub fn handle_server_messages(
362 cache: &NotificationCache,
363 limit: usize,
364 ) -> Result<ServerMessagesResult> {
365 let all_messages = cache.messages();
366 let messages: Vec<_> = all_messages.iter().take(limit).cloned().collect();
367 Ok(ServerMessagesResult { messages })
368 }
369}
370
371#[cfg(test)]
372#[allow(clippy::unwrap_used, clippy::expect_used)]
373mod tests {
374 use std::collections::HashMap;
375 use std::fs;
376 use std::sync::Arc;
377 use std::time::Duration;
378
379 use tempfile::TempDir;
380 use tokio::io::BufReader;
381 use tokio::time::timeout;
382 use url::Url;
383
384 use super::*;
385 use crate::bridge::translator::testing::*;
386 use crate::config::{ServerId, ToolRouter};
387
388 #[test]
394 fn test_document_diagnostic_params_omit_optional_null_fields() {
395 let uri = lsp_types::Uri::from("file:///test.ts");
396 let params = DocumentDiagnosticParams {
397 text_document: TextDocumentIdentifier { uri },
398 identifier: None,
399 previous_result_id: None,
400 work_done_progress_params: WorkDoneProgressParams::default(),
401 partial_result_params: PartialResultParams::default(),
402 };
403 let value = serde_json::to_value(params).unwrap();
404
405 assert_eq!(value["textDocument"]["uri"], "file:///test.ts");
406 assert!(value.get("identifier").is_none());
407 assert!(value.get("previousResultId").is_none());
408 }
409
410 #[tokio::test]
411 async fn test_handle_cached_diagnostics_empty() {
412 let cache = NotificationCache::new();
413 let temp_dir = TempDir::new().unwrap();
414 let test_file = temp_dir.path().join("test.rs");
415 fs::write(&test_file, "fn main() {}").unwrap();
416
417 let cache_key =
418 Translator::cached_diagnostics_uri(&[], test_file.to_str().unwrap()).unwrap();
419 let diag_info = cache.diagnostics(&cache_key).cloned();
420 let diags = Translator::diagnostics_from_cache_entry(
421 diag_info.as_ref(),
422 PositionEncoding::Utf16,
423 &test_tracker(),
424 )
425 .await;
426 assert_eq!(diags.diagnostics.len(), 0);
427 }
428
429 #[test]
430 fn test_handle_server_logs_with_filter() {
431 use crate::bridge::notifications::LogLevel;
432
433 let mut cache = NotificationCache::new();
434
435 cache.store_log(LogLevel::Error, "error msg".to_string());
437 cache.store_log(LogLevel::Warning, "warning msg".to_string());
438 cache.store_log(LogLevel::Info, "info msg".to_string());
439 cache.store_log(LogLevel::Debug, "debug msg".to_string());
440
441 let result = Translator::handle_server_logs(&cache, 10, Some("error".to_string()));
443 assert!(result.is_ok());
444 let logs = result.unwrap();
445 assert_eq!(logs.logs.len(), 1);
446 assert_eq!(logs.logs[0].message, "error msg");
447
448 let result = Translator::handle_server_logs(&cache, 10, Some("warning".to_string()));
450 assert!(result.is_ok());
451 let logs = result.unwrap();
452 assert_eq!(logs.logs.len(), 2);
453
454 let result = Translator::handle_server_logs(&cache, 10, Some("info".to_string()));
456 assert!(result.is_ok());
457 let logs = result.unwrap();
458 assert_eq!(logs.logs.len(), 3);
459
460 let result = Translator::handle_server_logs(&cache, 10, Some("debug".to_string()));
462 assert!(result.is_ok());
463 let logs = result.unwrap();
464 assert_eq!(logs.logs.len(), 4);
465
466 let result = Translator::handle_server_logs(&cache, 10, Some("invalid".to_string()));
468 assert!(matches!(result, Err(Error::InvalidToolParams(_))));
469 }
470
471 #[test]
472 fn test_handle_server_messages_limit() {
473 use crate::bridge::notifications::MessageType;
474
475 let mut cache = NotificationCache::new();
476
477 for i in 0..10 {
479 cache.store_message(MessageType::Info, format!("message {i}"));
480 }
481
482 let result = Translator::handle_server_messages(&cache, 5);
484 assert!(result.is_ok());
485 let messages = result.unwrap();
486 assert_eq!(messages.messages.len(), 5);
487 assert_eq!(messages.messages[0].message, "message 0");
488 assert_eq!(messages.messages[4].message, "message 4");
489
490 let result = Translator::handle_server_messages(&cache, 100);
492 assert!(result.is_ok());
493 let messages = result.unwrap();
494 assert_eq!(messages.messages.len(), 10);
495 }
496
497 #[tokio::test]
498 async fn test_handle_cached_diagnostics_with_data() {
499 let mut cache = NotificationCache::new();
500 let temp_dir = TempDir::new().unwrap();
501 let test_file = temp_dir.path().join("test.rs");
502 fs::write(&test_file, "fn main() {}").unwrap();
503
504 let canonical_path = test_file.canonicalize().unwrap();
505 let uri: lsp_types::Uri =
506 lsp_types::Uri::from(Url::from_file_path(&canonical_path).unwrap().as_str());
507 let diagnostic = lsp_types::Diagnostic {
508 range: lsp_types::Range {
509 start: lsp_types::Position {
510 line: 0,
511 character: 0,
512 },
513 end: lsp_types::Position {
514 line: 0,
515 character: 5,
516 },
517 },
518 severity: Some(lsp_types::DiagnosticSeverity::Error),
519 message: "test error".to_string().into(),
520 code: Some(lsp_types::Code::String("E001".to_string())),
521 source: None,
522 code_description: None,
523 related_information: None,
524 tags: None,
525 data: None,
526 };
527
528 cache.store_diagnostics(&ServerId::from("rust"), &uri, Some(1), vec![diagnostic]);
529
530 let cache_key =
531 Translator::cached_diagnostics_uri(&[], test_file.to_str().unwrap()).unwrap();
532 let diag_info = cache.diagnostics(&cache_key).cloned();
533 let diags = Translator::diagnostics_from_cache_entry(
534 diag_info.as_ref(),
535 PositionEncoding::Utf16,
536 &test_tracker(),
537 )
538 .await;
539 assert_eq!(diags.diagnostics.len(), 1);
540 assert_eq!(diags.diagnostics[0].message, "test error");
541 assert_eq!(diags.diagnostics[0].code, Some("E001".to_string()));
542 assert!(matches!(
543 diags.diagnostics[0].severity,
544 DiagnosticSeverity::Error
545 ));
546 assert_eq!(diags.diagnostics[0].range.start.line, 1);
547 assert_eq!(diags.diagnostics[0].range.start.character, 1);
548 }
549
550 #[tokio::test]
551 #[allow(clippy::too_many_lines)]
552 async fn test_handle_cached_diagnostics_multiple_severities() {
553 let mut cache = NotificationCache::new();
554 let temp_dir = TempDir::new().unwrap();
555 let test_file = temp_dir.path().join("test.rs");
556 fs::write(&test_file, "fn main() {}").unwrap();
557
558 let canonical_path = test_file.canonicalize().unwrap();
559 let uri: lsp_types::Uri =
560 lsp_types::Uri::from(Url::from_file_path(&canonical_path).unwrap().as_str());
561 let diagnostics = vec![
562 lsp_types::Diagnostic {
563 range: lsp_types::Range {
564 start: lsp_types::Position {
565 line: 0,
566 character: 0,
567 },
568 end: lsp_types::Position {
569 line: 0,
570 character: 5,
571 },
572 },
573 severity: Some(lsp_types::DiagnosticSeverity::Error),
574 message: "error".to_string().into(),
575 code: None,
576 source: None,
577 code_description: None,
578 related_information: None,
579 tags: None,
580 data: None,
581 },
582 lsp_types::Diagnostic {
583 range: lsp_types::Range {
584 start: lsp_types::Position {
585 line: 1,
586 character: 0,
587 },
588 end: lsp_types::Position {
589 line: 1,
590 character: 5,
591 },
592 },
593 severity: Some(lsp_types::DiagnosticSeverity::Warning),
594 message: "warning".to_string().into(),
595 code: None,
596 source: None,
597 code_description: None,
598 related_information: None,
599 tags: None,
600 data: None,
601 },
602 lsp_types::Diagnostic {
603 range: lsp_types::Range {
604 start: lsp_types::Position {
605 line: 2,
606 character: 0,
607 },
608 end: lsp_types::Position {
609 line: 2,
610 character: 5,
611 },
612 },
613 severity: Some(lsp_types::DiagnosticSeverity::Information),
614 message: "info".to_string().into(),
615 code: None,
616 source: None,
617 code_description: None,
618 related_information: None,
619 tags: None,
620 data: None,
621 },
622 lsp_types::Diagnostic {
623 range: lsp_types::Range {
624 start: lsp_types::Position {
625 line: 3,
626 character: 0,
627 },
628 end: lsp_types::Position {
629 line: 3,
630 character: 5,
631 },
632 },
633 severity: Some(lsp_types::DiagnosticSeverity::Hint),
634 message: "hint".to_string().into(),
635 code: None,
636 source: None,
637 code_description: None,
638 related_information: None,
639 tags: None,
640 data: None,
641 },
642 ];
643
644 cache.store_diagnostics(&ServerId::from("rust"), &uri, Some(1), diagnostics);
645
646 let cache_key =
647 Translator::cached_diagnostics_uri(&[], test_file.to_str().unwrap()).unwrap();
648 let diag_info = cache.diagnostics(&cache_key).cloned();
649 let diags = Translator::diagnostics_from_cache_entry(
650 diag_info.as_ref(),
651 PositionEncoding::Utf16,
652 &test_tracker(),
653 )
654 .await;
655 assert_eq!(diags.diagnostics.len(), 4);
656 assert!(matches!(
657 diags.diagnostics[0].severity,
658 DiagnosticSeverity::Error
659 ));
660 assert!(matches!(
661 diags.diagnostics[1].severity,
662 DiagnosticSeverity::Warning
663 ));
664 assert!(matches!(
665 diags.diagnostics[2].severity,
666 DiagnosticSeverity::Information
667 ));
668 assert!(matches!(
669 diags.diagnostics[3].severity,
670 DiagnosticSeverity::Hint
671 ));
672 }
673
674 #[tokio::test]
675 async fn test_handle_cached_diagnostics_with_numeric_code() {
676 let mut cache = NotificationCache::new();
677 let temp_dir = TempDir::new().unwrap();
678 let test_file = temp_dir.path().join("test.rs");
679 fs::write(&test_file, "fn main() {}").unwrap();
680
681 let canonical_path = test_file.canonicalize().unwrap();
682 let uri: lsp_types::Uri =
683 lsp_types::Uri::from(Url::from_file_path(&canonical_path).unwrap().as_str());
684 let diagnostic = lsp_types::Diagnostic {
685 range: lsp_types::Range {
686 start: lsp_types::Position {
687 line: 0,
688 character: 0,
689 },
690 end: lsp_types::Position {
691 line: 0,
692 character: 5,
693 },
694 },
695 severity: Some(lsp_types::DiagnosticSeverity::Error),
696 message: "test error".to_string().into(),
697 code: Some(lsp_types::Code::Int(42)),
698 source: None,
699 code_description: None,
700 related_information: None,
701 tags: None,
702 data: None,
703 };
704
705 cache.store_diagnostics(&ServerId::from("rust"), &uri, Some(1), vec![diagnostic]);
706
707 let cache_key =
708 Translator::cached_diagnostics_uri(&[], test_file.to_str().unwrap()).unwrap();
709 let diag_info = cache.diagnostics(&cache_key).cloned();
710 let diags = Translator::diagnostics_from_cache_entry(
711 diag_info.as_ref(),
712 PositionEncoding::Utf16,
713 &test_tracker(),
714 )
715 .await;
716 assert_eq!(diags.diagnostics.len(), 1);
717 assert_eq!(diags.diagnostics[0].code, Some("42".to_string()));
718 }
719
720 #[test]
721 fn test_handle_cached_diagnostics_invalid_path() {
722 let result = Translator::cached_diagnostics_uri(&[], "/nonexistent/path/file.rs");
723 assert!(matches!(result, Err(Error::FileIo { .. })));
724 }
725
726 #[tokio::test]
727 async fn test_merge_diagnostics_cache_only_appends_to_empty_pull() {
728 let pull = DiagnosticsResult {
729 diagnostics: vec![],
730 };
731 let cache = diag_info(vec![lsp_diag(
732 0,
733 10,
734 lsp_types::DiagnosticSeverity::Warning,
735 "unused import: `std::fmt`",
736 None,
737 )]);
738
739 let merged = Translator::merge_diagnostics(
740 pull,
741 Some(&cache),
742 PositionEncoding::Utf16,
743 &test_tracker(),
744 )
745 .await;
746
747 assert_eq!(merged.diagnostics.len(), 1);
748 assert_eq!(merged.diagnostics[0].message, "unused import: `std::fmt`");
749 assert!(matches!(
750 merged.diagnostics[0].severity,
751 DiagnosticSeverity::Warning
752 ));
753 }
754
755 #[tokio::test]
756 async fn test_merge_diagnostics_exact_duplicate_not_repeated() {
757 let pull_diag = Diagnostic {
760 range: Range {
761 start: Position2D {
762 line: 1,
763 character: 1,
764 },
765 end: Position2D {
766 line: 1,
767 character: 11,
768 },
769 },
770 severity: DiagnosticSeverity::Error,
771 message: "mismatched types".to_string(),
772 code: Some("E0308".to_string()),
773 };
774 let pull = DiagnosticsResult {
775 diagnostics: vec![pull_diag.clone()],
776 };
777 let cache = diag_info(vec![lsp_diag(
778 0,
779 10,
780 lsp_types::DiagnosticSeverity::Error,
781 "mismatched types",
782 Some("E0308"),
783 )]);
784
785 let merged = Translator::merge_diagnostics(
786 pull,
787 Some(&cache),
788 PositionEncoding::Utf16,
789 &test_tracker(),
790 )
791 .await;
792
793 assert_eq!(merged.diagnostics.len(), 1);
794 assert_eq!(merged.diagnostics[0], pull_diag);
795 }
796
797 #[tokio::test]
798 async fn test_merge_diagnostics_no_cache_entry_returns_pull_unchanged() {
799 let pull_diag = Diagnostic {
800 range: Range {
801 start: Position2D {
802 line: 1,
803 character: 1,
804 },
805 end: Position2D {
806 line: 1,
807 character: 5,
808 },
809 },
810 severity: DiagnosticSeverity::Error,
811 message: "syntax error".to_string(),
812 code: None,
813 };
814 let pull = DiagnosticsResult {
815 diagnostics: vec![pull_diag.clone()],
816 };
817
818 let merged =
819 Translator::merge_diagnostics(pull, None, PositionEncoding::Utf16, &test_tracker())
820 .await;
821
822 assert_eq!(merged.diagnostics, vec![pull_diag]);
823 }
824
825 #[tokio::test]
826 async fn test_merge_diagnostics_multiple_distinct_cache_entries_all_appear() {
827 let pull = DiagnosticsResult {
828 diagnostics: vec![],
829 };
830 let cache = diag_info(vec![
831 lsp_diag(
832 0,
833 10,
834 lsp_types::DiagnosticSeverity::Warning,
835 "unused import: `std::fmt`",
836 None,
837 ),
838 lsp_diag(
839 5,
840 8,
841 lsp_types::DiagnosticSeverity::Warning,
842 "function `helper` is never used",
843 None,
844 ),
845 ]);
846
847 let merged = Translator::merge_diagnostics(
848 pull,
849 Some(&cache),
850 PositionEncoding::Utf16,
851 &test_tracker(),
852 )
853 .await;
854
855 assert_eq!(merged.diagnostics.len(), 2);
856 assert!(
857 merged
858 .diagnostics
859 .iter()
860 .any(|d| d.message == "unused import: `std::fmt`")
861 );
862 assert!(
863 merged
864 .diagnostics
865 .iter()
866 .any(|d| d.message == "function `helper` is never used")
867 );
868 }
869
870 #[tokio::test]
871 async fn test_merge_diagnostics_same_range_different_message_not_deduped() {
872 let pull_diag = Diagnostic {
873 range: Range {
874 start: Position2D {
875 line: 1,
876 character: 1,
877 },
878 end: Position2D {
879 line: 1,
880 character: 11,
881 },
882 },
883 severity: DiagnosticSeverity::Error,
884 message: "mismatched types".to_string(),
885 code: None,
886 };
887 let pull = DiagnosticsResult {
888 diagnostics: vec![pull_diag],
889 };
890 let cache = diag_info(vec![lsp_diag(
893 0,
894 10,
895 lsp_types::DiagnosticSeverity::Error,
896 "expected `i32`, found `&str`",
897 None,
898 )]);
899
900 let merged = Translator::merge_diagnostics(
901 pull,
902 Some(&cache),
903 PositionEncoding::Utf16,
904 &test_tracker(),
905 )
906 .await;
907
908 assert_eq!(merged.diagnostics.len(), 2);
909 }
910
911 #[tokio::test]
919 async fn test_merge_diagnostics_same_code_different_range_and_message_deduped() {
920 let pull_diag = Diagnostic {
921 range: Range {
922 start: Position2D {
923 line: 96,
924 character: 7,
925 },
926 end: Position2D {
927 line: 96,
928 character: 12,
929 },
930 },
931 severity: DiagnosticSeverity::Error,
932 message: "not all trait items implemented, missing: `fn hello`".to_string(),
933 code: Some("E0046".to_string()),
934 };
935 let pull = DiagnosticsResult {
936 diagnostics: vec![pull_diag.clone()],
937 };
938 let cache = diag_info(vec![lsp_diag(
942 94,
943 31,
944 lsp_types::DiagnosticSeverity::Error,
945 "not all trait items implemented, missing: `hello`\nmissing `hello` in implementation",
946 Some("E0046"),
947 )]);
948
949 let merged = Translator::merge_diagnostics(
950 pull,
951 Some(&cache),
952 PositionEncoding::Utf16,
953 &test_tracker(),
954 )
955 .await;
956
957 assert_eq!(merged.diagnostics.len(), 1);
958 assert_eq!(merged.diagnostics[0], pull_diag);
959 }
960
961 #[tokio::test]
976 async fn test_merge_diagnostics_same_code_distinct_diagnostics_at_different_locations_both_kept()
977 {
978 let pull_diag = Diagnostic {
979 range: Range {
980 start: Position2D {
981 line: 5,
982 character: 9,
983 },
984 end: Position2D {
985 line: 5,
986 character: 20,
987 },
988 },
989 severity: DiagnosticSeverity::Error,
990 message: "mismatched types: expected `i32`, found `&str`".to_string(),
991 code: Some("E0308".to_string()),
992 };
993 let pull = DiagnosticsResult {
994 diagnostics: vec![pull_diag.clone()],
995 };
996 let cache = diag_info(vec![lsp_diag(
1000 49,
1001 22,
1002 lsp_types::DiagnosticSeverity::Error,
1003 "mismatched types: expected `String`, found `Vec<u8>`",
1004 Some("E0308"),
1005 )]);
1006
1007 let merged = Translator::merge_diagnostics(
1008 pull,
1009 Some(&cache),
1010 PositionEncoding::Utf16,
1011 &test_tracker(),
1012 )
1013 .await;
1014
1015 assert_eq!(merged.diagnostics.len(), 2);
1016 assert_eq!(merged.diagnostics[0], pull_diag);
1017 assert_eq!(
1018 merged.diagnostics[1].message,
1019 "mismatched types: expected `String`, found `Vec<u8>`"
1020 );
1021 }
1022
1023 #[test]
1024 fn test_handle_server_logs_no_filter() {
1025 use crate::bridge::notifications::LogLevel;
1026
1027 let mut cache = NotificationCache::new();
1028
1029 cache.store_log(LogLevel::Error, "error msg".to_string());
1030 cache.store_log(LogLevel::Warning, "warning msg".to_string());
1031 cache.store_log(LogLevel::Info, "info msg".to_string());
1032 cache.store_log(LogLevel::Debug, "debug msg".to_string());
1033
1034 let result = Translator::handle_server_logs(&cache, 10, None);
1035 assert!(result.is_ok());
1036 let logs = result.unwrap();
1037 assert_eq!(logs.logs.len(), 4);
1038 }
1039
1040 #[test]
1041 fn test_handle_server_logs_error_filter_strict() {
1042 use crate::bridge::notifications::LogLevel;
1043
1044 let mut cache = NotificationCache::new();
1045
1046 cache.store_log(LogLevel::Error, "error msg".to_string());
1047 cache.store_log(LogLevel::Warning, "warning msg".to_string());
1048 cache.store_log(LogLevel::Info, "info msg".to_string());
1049
1050 let result = Translator::handle_server_logs(&cache, 10, Some("error".to_string()));
1051 assert!(result.is_ok());
1052 let logs = result.unwrap();
1053 assert_eq!(logs.logs.len(), 1);
1054 assert_eq!(logs.logs[0].message, "error msg");
1055 }
1056
1057 #[test]
1058 fn test_handle_server_logs_warning_filter_includes_errors() {
1059 use crate::bridge::notifications::LogLevel;
1060
1061 let mut cache = NotificationCache::new();
1062
1063 cache.store_log(LogLevel::Error, "error msg".to_string());
1064 cache.store_log(LogLevel::Warning, "warning msg".to_string());
1065 cache.store_log(LogLevel::Info, "info msg".to_string());
1066
1067 let result = Translator::handle_server_logs(&cache, 10, Some("warning".to_string()));
1068 assert!(result.is_ok());
1069 let logs = result.unwrap();
1070 assert_eq!(logs.logs.len(), 2);
1071 }
1072
1073 #[test]
1074 fn test_handle_server_logs_info_filter_excludes_debug() {
1075 use crate::bridge::notifications::LogLevel;
1076
1077 let mut cache = NotificationCache::new();
1078
1079 cache.store_log(LogLevel::Error, "error msg".to_string());
1080 cache.store_log(LogLevel::Info, "info msg".to_string());
1081 cache.store_log(LogLevel::Debug, "debug msg".to_string());
1082
1083 let result = Translator::handle_server_logs(&cache, 10, Some("info".to_string()));
1084 assert!(result.is_ok());
1085 let logs = result.unwrap();
1086 assert_eq!(logs.logs.len(), 2);
1087 }
1088
1089 #[test]
1090 fn test_handle_server_logs_debug_filter_includes_all() {
1091 use crate::bridge::notifications::LogLevel;
1092
1093 let mut cache = NotificationCache::new();
1094
1095 cache.store_log(LogLevel::Error, "error msg".to_string());
1096 cache.store_log(LogLevel::Warning, "warning msg".to_string());
1097 cache.store_log(LogLevel::Info, "info msg".to_string());
1098 cache.store_log(LogLevel::Debug, "debug msg".to_string());
1099
1100 let result = Translator::handle_server_logs(&cache, 10, Some("debug".to_string()));
1101 assert!(result.is_ok());
1102 let logs = result.unwrap();
1103 assert_eq!(logs.logs.len(), 4);
1104 }
1105
1106 #[test]
1107 fn test_handle_server_logs_limit_applies_after_filter() {
1108 use crate::bridge::notifications::LogLevel;
1109
1110 let mut cache = NotificationCache::new();
1111
1112 for i in 0..10 {
1113 cache.store_log(LogLevel::Error, format!("error {i}"));
1114 }
1115
1116 let result = Translator::handle_server_logs(&cache, 5, Some("error".to_string()));
1117 assert!(result.is_ok());
1118 let logs = result.unwrap();
1119 assert_eq!(logs.logs.len(), 5);
1120 assert_eq!(logs.logs[0].message, "error 0");
1121 assert_eq!(logs.logs[4].message, "error 4");
1122 }
1123
1124 #[test]
1125 fn test_handle_server_logs_case_insensitive_level() {
1126 use crate::bridge::notifications::LogLevel;
1127
1128 let mut cache = NotificationCache::new();
1129
1130 cache.store_log(LogLevel::Error, "error msg".to_string());
1131
1132 let result = Translator::handle_server_logs(&cache, 10, Some("ERROR".to_string()));
1133 assert!(result.is_ok());
1134
1135 let result = Translator::handle_server_logs(&cache, 10, Some("Error".to_string()));
1136 assert!(result.is_ok());
1137
1138 let result = Translator::handle_server_logs(&cache, 10, Some("eRrOr".to_string()));
1139 assert!(result.is_ok());
1140 }
1141
1142 #[test]
1143 fn test_handle_server_messages_empty() {
1144 let cache = NotificationCache::new();
1145
1146 let result = Translator::handle_server_messages(&cache, 10);
1147 assert!(result.is_ok());
1148 let messages = result.unwrap();
1149 assert_eq!(messages.messages.len(), 0);
1150 }
1151
1152 #[test]
1153 fn test_handle_server_messages_with_different_types() {
1154 use crate::bridge::notifications::MessageType;
1155
1156 let mut cache = NotificationCache::new();
1157
1158 cache.store_message(MessageType::Error, "error".to_string());
1159 cache.store_message(MessageType::Warning, "warning".to_string());
1160 cache.store_message(MessageType::Info, "info".to_string());
1161 cache.store_message(MessageType::Log, "log".to_string());
1162
1163 let result = Translator::handle_server_messages(&cache, 10);
1164 assert!(result.is_ok());
1165 let messages = result.unwrap();
1166 assert_eq!(messages.messages.len(), 4);
1167 assert_eq!(messages.messages[0].message, "error");
1168 assert_eq!(messages.messages[1].message, "warning");
1169 assert_eq!(messages.messages[2].message, "info");
1170 assert_eq!(messages.messages[3].message, "log");
1171 }
1172
1173 #[test]
1174 fn test_handle_server_messages_zero_limit() {
1175 use crate::bridge::notifications::MessageType;
1176
1177 let mut cache = NotificationCache::new();
1178
1179 cache.store_message(MessageType::Info, "test".to_string());
1180
1181 let result = Translator::handle_server_messages(&cache, 0);
1182 assert!(result.is_ok());
1183 let messages = result.unwrap();
1184 assert_eq!(messages.messages.len(), 0);
1185 }
1186
1187 #[test]
1188 fn test_handle_cached_diagnostics_path_outside_workspace() {
1189 let temp_dir1 = TempDir::new().unwrap();
1190 let temp_dir2 = TempDir::new().unwrap();
1191
1192 let workspace_roots = vec![temp_dir1.path().to_path_buf()];
1193
1194 let test_file = temp_dir2.path().join("test.rs");
1195 fs::write(&test_file, "fn main() {}").unwrap();
1196
1197 let result =
1198 Translator::cached_diagnostics_uri(&workspace_roots, test_file.to_str().unwrap());
1199 assert!(matches!(result, Err(Error::PathOutsideWorkspace(_))));
1200 }
1201
1202 #[tokio::test]
1207 async fn test_handle_diagnostics_pull_error_falls_back_to_nonempty_cache() {
1208 let dir = TempDir::new().unwrap();
1209 let mut extensions = HashMap::new();
1210 extensions.insert("rs".to_string(), "rust".to_string());
1211
1212 let mut translator =
1213 Translator::new()
1214 .with_extensions(extensions)
1215 .with_router(ToolRouter::catch_all([(
1216 ServerId::from("rust"),
1217 "rust".to_string(),
1218 )]));
1219 translator.set_workspace_roots(vec![dir.path().to_path_buf()]);
1220
1221 let (client, mut server) = fake_lsp_client();
1222 translator.register_client("rust".to_string(), client);
1223
1224 let path = dir.path().join("lib.rs");
1225 fs::write(&path, "fn main() {}").unwrap();
1226 let path_str = path.to_string_lossy().to_string();
1227
1228 let canonical = path.canonicalize().unwrap();
1232 let uri = path_to_uri(&canonical).unwrap();
1233 let notification_cache = Mutex::new(NotificationCache::new());
1234 {
1235 let mut cache = notification_cache.lock().await;
1236 cache.store_diagnostics(
1237 &ServerId::from("rust"),
1238 &uri,
1239 Some(1),
1240 vec![lsp_diag(
1241 0,
1242 4,
1243 lsp_types::DiagnosticSeverity::Warning,
1244 "unused import: `std::fmt`",
1245 None,
1246 )],
1247 );
1248 }
1249
1250 let translator = Arc::new(translator);
1251 let handle = {
1252 let translator = Arc::clone(&translator);
1253 tokio::spawn(async move {
1254 translator
1255 .handle_diagnostics(path_str, ¬ification_cache)
1256 .await
1257 })
1258 };
1259
1260 let mut wire = BufReader::new(&mut server.write_stdout);
1261 let opened = read_framed_message(&mut wire).await;
1262 assert_eq!(opened["method"], "textDocument/didOpen");
1263 let diag_request = read_framed_message(&mut wire).await;
1264 assert_eq!(diag_request["method"], "textDocument/diagnostic");
1265 write_error_response(
1266 &mut server.read_half_stdin,
1267 &diag_request["id"],
1268 -32601,
1269 "method not found",
1270 )
1271 .await;
1272
1273 let result = timeout(Duration::from_secs(2), handle)
1274 .await
1275 .expect("handler call should not hang")
1276 .unwrap();
1277
1278 let diagnostics = result.expect("cache-only fallback should succeed despite pull error");
1279 assert_eq!(diagnostics.diagnostics.len(), 1);
1280 assert_eq!(
1281 diagnostics.diagnostics[0].message,
1282 "unused import: `std::fmt`"
1283 );
1284 }
1285
1286 #[tokio::test]
1295 async fn test_handle_diagnostics_partial_response_degrades_to_empty_result() {
1296 let dir = TempDir::new().unwrap();
1297 let mut extensions = HashMap::new();
1298 extensions.insert("rs".to_string(), "rust".to_string());
1299
1300 let mut translator =
1301 Translator::new()
1302 .with_extensions(extensions)
1303 .with_router(ToolRouter::catch_all([(
1304 ServerId::from("rust"),
1305 "rust".to_string(),
1306 )]));
1307 translator.set_workspace_roots(vec![dir.path().to_path_buf()]);
1308
1309 let (client, mut server) = fake_lsp_client();
1310 translator.register_client("rust".to_string(), client);
1311
1312 let path = dir.path().join("lib.rs");
1313 fs::write(&path, "fn main() {}").unwrap();
1314 let path_str = path.to_string_lossy().to_string();
1315
1316 let notification_cache = Mutex::new(NotificationCache::new());
1317
1318 let translator = Arc::new(translator);
1319 let handle = {
1320 let translator = Arc::clone(&translator);
1321 tokio::spawn(async move {
1322 translator
1323 .handle_diagnostics(path_str, ¬ification_cache)
1324 .await
1325 })
1326 };
1327
1328 let mut wire = BufReader::new(&mut server.write_stdout);
1329 let opened = read_framed_message(&mut wire).await;
1330 assert_eq!(opened["method"], "textDocument/didOpen");
1331 let diag_request = read_framed_message(&mut wire).await;
1332 assert_eq!(diag_request["method"], "textDocument/diagnostic");
1333
1334 write_response(
1337 &mut server.read_half_stdin,
1338 &diag_request["id"],
1339 serde_json::json!({ "relatedDocuments": {} }),
1340 )
1341 .await;
1342
1343 let result = timeout(Duration::from_secs(2), handle)
1344 .await
1345 .expect("handler call should not hang")
1346 .unwrap();
1347
1348 let diagnostics =
1349 result.expect("a Partial response must degrade to Ok(empty), not an error");
1350 assert!(
1351 diagnostics.diagnostics.is_empty(),
1352 "expected no diagnostics from a Partial response, got {diagnostics:?}"
1353 );
1354 }
1355
1356 #[tokio::test]
1359 async fn test_handle_diagnostics_pull_error_and_empty_cache_propagates_error() {
1360 let dir = TempDir::new().unwrap();
1361 let mut extensions = HashMap::new();
1362 extensions.insert("rs".to_string(), "rust".to_string());
1363
1364 let mut translator =
1365 Translator::new()
1366 .with_extensions(extensions)
1367 .with_router(ToolRouter::catch_all([(
1368 ServerId::from("rust"),
1369 "rust".to_string(),
1370 )]));
1371 translator.set_workspace_roots(vec![dir.path().to_path_buf()]);
1372
1373 let (client, mut server) = fake_lsp_client();
1374 translator.register_client("rust".to_string(), client);
1375
1376 let path = dir.path().join("lib.rs");
1377 fs::write(&path, "fn main() {}").unwrap();
1378 let path_str = path.to_string_lossy().to_string();
1379
1380 let notification_cache = Mutex::new(NotificationCache::new());
1381
1382 let translator = Arc::new(translator);
1383 let handle = {
1384 let translator = Arc::clone(&translator);
1385 tokio::spawn(async move {
1386 translator
1387 .handle_diagnostics(path_str, ¬ification_cache)
1388 .await
1389 })
1390 };
1391
1392 let mut wire = BufReader::new(&mut server.write_stdout);
1393 let opened = read_framed_message(&mut wire).await;
1394 assert_eq!(opened["method"], "textDocument/didOpen");
1395 let diag_request = read_framed_message(&mut wire).await;
1396 assert_eq!(diag_request["method"], "textDocument/diagnostic");
1397 write_error_response(
1398 &mut server.read_half_stdin,
1399 &diag_request["id"],
1400 -32601,
1401 "method not found",
1402 )
1403 .await;
1404
1405 let result = timeout(Duration::from_secs(2), handle)
1406 .await
1407 .expect("handler call should not hang")
1408 .unwrap();
1409
1410 assert!(
1411 result.is_err(),
1412 "pull error with no cache data must propagate, got {result:?}"
1413 );
1414 }
1415}