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