1use std::collections::{HashMap, HashSet};
4use std::path::{Path, PathBuf};
5use std::sync::{Arc, Mutex as StdMutex};
6
7use lsp_types::{
8 CallHierarchyIncomingCall, CallHierarchyIncomingCallsParams, CallHierarchyItem,
9 CallHierarchyOutgoingCall, CallHierarchyOutgoingCallsParams,
10 CallHierarchyPrepareParams as LspCallHierarchyPrepareParams, CompletionParams,
11 CompletionTriggerKind, DocumentFormattingParams, DocumentSymbol, DocumentSymbolParams,
12 FormattingOptions, GotoDefinitionParams, Hover, HoverContents, HoverParams as LspHoverParams,
13 InlayHintLabel, InlayHintParams, MarkedString, PartialResultParams, ReferenceContext,
14 ReferenceParams, RenameParams as LspRenameParams,
15 SignatureHelpParams as LspSignatureHelpParams, TextDocumentIdentifier,
16 TextDocumentPositionParams, WorkDoneProgressParams, WorkspaceEdit,
17 WorkspaceSymbolParams as LspWorkspaceSymbolParams,
18};
19use serde::{Deserialize, Serialize};
20use tokio::sync::Mutex;
21use tokio::time::Duration;
22
23use super::state::{ResourceLimits, detect_language, path_to_uri};
24use super::{DiagnosticInfo, DocumentTracker, NotificationCache, lock_std};
25use crate::bridge::encoding::mcp_to_lsp_position;
26use crate::config::{ServerId, ToolKind, ToolRouter, base_language_id};
27use crate::error::{Error, Result};
28use crate::lsp::{LspClient, LspServer};
29
30#[derive(Debug)]
46pub struct Translator {
47 lsp_clients: Arc<StdMutex<HashMap<ServerId, LspClient>>>,
50 lsp_servers: Arc<StdMutex<HashMap<ServerId, LspServer>>>,
52 document_tracker: Arc<DocumentTracker>,
54 workspace_roots: Arc<Vec<PathBuf>>,
57 extension_map: Arc<HashMap<String, String>>,
60 expected_servers: Arc<StdMutex<HashSet<ServerId>>>,
64 router: Arc<StdMutex<ToolRouter>>,
69}
70
71impl Translator {
72 #[must_use]
77 pub fn new() -> Self {
78 Self {
79 lsp_clients: Arc::new(StdMutex::new(HashMap::new())),
80 lsp_servers: Arc::new(StdMutex::new(HashMap::new())),
81 document_tracker: Arc::new(DocumentTracker::new(
82 ResourceLimits::default(),
83 HashMap::new(),
84 )),
85 workspace_roots: Arc::new(Vec::new()),
86 extension_map: Arc::new(HashMap::new()),
87 expected_servers: Arc::new(StdMutex::new(HashSet::new())),
88 router: Arc::new(StdMutex::new(ToolRouter::default())),
89 }
90 }
91
92 pub fn set_workspace_roots(&mut self, roots: Vec<PathBuf>) {
97 self.workspace_roots = Arc::new(roots);
98 }
99
100 pub fn set_expected_servers(&self, servers: HashSet<ServerId>) {
103 *lock_std(&self.expected_servers) = servers;
104 }
105
106 pub fn clear_expected_servers(&self) {
108 lock_std(&self.expected_servers).clear();
109 }
110
111 #[must_use]
116 pub fn with_router(mut self, router: ToolRouter) -> Self {
117 self.router = Arc::new(StdMutex::new(router));
118 self
119 }
120
121 pub fn rebind_router(&self, registered: &HashSet<ServerId>) {
125 lock_std(&self.router).rebind_to_registered(registered);
126 }
127
128 #[must_use]
135 pub fn is_diagnostics_route(&self, language_id: &str, id: &ServerId) -> bool {
136 lock_std(&self.router).resolve(language_id, ToolKind::Diagnostics) == Some(id)
137 }
138
139 #[must_use]
147 pub fn with_extensions(mut self, extension_map: HashMap<String, String>) -> Self {
148 self.document_tracker = Arc::new(DocumentTracker::new(
149 ResourceLimits::default(),
150 extension_map.clone(),
151 ));
152 self.extension_map = Arc::new(extension_map);
153 self
154 }
155
156 pub fn register_client(&self, id: impl Into<ServerId>, client: LspClient) {
164 lock_std(&self.lsp_clients).insert(id.into(), client);
165 }
166
167 pub fn register_server(&self, id: impl Into<ServerId>, server: LspServer) {
169 lock_std(&self.lsp_servers).insert(id.into(), server);
170 }
171
172 #[must_use]
174 pub fn open_document_paths(&self) -> Vec<PathBuf> {
175 self.document_tracker.open_paths()
176 }
177
178 #[must_use]
180 pub fn is_document_open(&self, path: &Path) -> bool {
181 self.document_tracker.is_open(path)
182 }
183
184 }
189
190impl Default for Translator {
191 fn default() -> Self {
192 Self::new()
193 }
194}
195
196#[derive(Debug, Serialize)]
197#[serde(rename_all = "camelCase")]
198struct DiagnosticRequestParams {
199 text_document: TextDocumentIdentifier,
200 #[serde(skip_serializing_if = "Option::is_none")]
201 identifier: Option<String>,
202 #[serde(skip_serializing_if = "Option::is_none")]
203 previous_result_id: Option<String>,
204 #[serde(flatten)]
205 work_done_progress_params: WorkDoneProgressParams,
206 #[serde(flatten)]
207 partial_result_params: PartialResultParams,
208}
209
210fn diagnostic_request_params(text_document: TextDocumentIdentifier) -> DiagnosticRequestParams {
211 DiagnosticRequestParams {
212 text_document,
213 identifier: None,
214 previous_result_id: None,
215 work_done_progress_params: WorkDoneProgressParams::default(),
216 partial_result_params: PartialResultParams::default(),
217 }
218}
219
220#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
222pub struct Position2D {
223 pub line: u32,
225 pub character: u32,
227}
228
229#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
231pub struct Range {
232 pub start: Position2D,
234 pub end: Position2D,
236}
237
238#[derive(Debug, Clone, Serialize, Deserialize)]
240pub struct Location {
241 pub uri: String,
243 pub range: Range,
245}
246
247#[derive(Debug, Clone, Serialize, Deserialize)]
249pub struct HoverResult {
250 pub contents: String,
252 pub range: Option<Range>,
254}
255
256#[derive(Debug, Clone, Serialize, Deserialize)]
258pub struct DefinitionResult {
259 pub locations: Vec<Location>,
261}
262
263#[derive(Debug, Clone, Serialize, Deserialize)]
265pub struct ReferencesResult {
266 pub locations: Vec<Location>,
268}
269
270#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
272#[serde(rename_all = "lowercase")]
273pub enum DiagnosticSeverity {
274 Error,
276 Warning,
278 Information,
280 Hint,
282}
283
284#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
286pub struct Diagnostic {
287 pub range: Range,
289 pub severity: DiagnosticSeverity,
291 pub message: String,
293 pub code: Option<String>,
295}
296
297#[derive(Debug, Clone, Serialize, Deserialize)]
299pub struct DiagnosticsResult {
300 pub diagnostics: Vec<Diagnostic>,
302}
303
304#[derive(Debug, Clone, Serialize, Deserialize)]
306pub struct TextEdit {
307 pub range: Range,
309 pub new_text: String,
311}
312
313#[derive(Debug, Clone, Serialize, Deserialize)]
315pub struct DocumentChanges {
316 pub uri: String,
318 pub edits: Vec<TextEdit>,
320}
321
322#[derive(Debug, Clone, Serialize, Deserialize)]
324pub struct RenameResult {
325 pub changes: Vec<DocumentChanges>,
327}
328
329#[derive(Debug, Clone, Serialize, Deserialize)]
331pub struct Completion {
332 pub label: String,
334 pub kind: Option<String>,
336 pub detail: Option<String>,
338 pub documentation: Option<String>,
340}
341
342#[derive(Debug, Clone, Serialize, Deserialize)]
344pub struct CompletionsResult {
345 pub items: Vec<Completion>,
347}
348
349#[derive(Debug, Clone, Serialize, Deserialize)]
351pub struct Symbol {
352 pub name: String,
354 pub kind: String,
356 pub range: Range,
358 pub selection_range: Range,
360 #[serde(skip_serializing_if = "Option::is_none")]
362 pub children: Option<Vec<Self>>,
363}
364
365#[derive(Debug, Clone, Serialize, Deserialize)]
367pub struct DocumentSymbolsResult {
368 pub symbols: Vec<Symbol>,
370}
371
372#[derive(Debug, Clone, Serialize, Deserialize)]
374pub struct FormatDocumentResult {
375 pub edits: Vec<TextEdit>,
377}
378
379#[derive(Debug, Clone, Serialize, Deserialize)]
381pub struct WorkspaceSymbol {
382 pub name: String,
384 pub kind: String,
386 pub location: Location,
388 #[serde(skip_serializing_if = "Option::is_none")]
390 pub container_name: Option<String>,
391}
392
393#[derive(Debug, Clone, Serialize, Deserialize)]
395pub struct WorkspaceSymbolResult {
396 pub symbols: Vec<WorkspaceSymbol>,
398}
399
400#[derive(Debug, Clone, Serialize, Deserialize)]
402pub struct CodeAction {
403 pub title: String,
405 #[serde(skip_serializing_if = "Option::is_none")]
407 pub kind: Option<String>,
408 #[serde(skip_serializing_if = "Vec::is_empty", default)]
410 pub diagnostics: Vec<Diagnostic>,
411 #[serde(skip_serializing_if = "Option::is_none")]
413 pub edit: Option<WorkspaceEditDescription>,
414 #[serde(skip_serializing_if = "Option::is_none")]
416 pub command: Option<CommandDescription>,
417 #[serde(default)]
419 pub is_preferred: bool,
420}
421
422#[derive(Debug, Clone, Serialize, Deserialize)]
424pub struct WorkspaceEditDescription {
425 pub changes: Vec<DocumentChanges>,
427}
428
429#[derive(Debug, Clone, Serialize, Deserialize)]
431pub struct CommandDescription {
432 pub title: String,
434 pub command: String,
436 #[serde(skip_serializing_if = "Vec::is_empty", default)]
438 pub arguments: Vec<serde_json::Value>,
439}
440
441#[derive(Debug, Clone, Serialize, Deserialize)]
443pub struct CodeActionsResult {
444 pub actions: Vec<CodeAction>,
446}
447
448#[derive(Debug, Clone, Serialize, Deserialize)]
450pub struct CallHierarchyItemResult {
451 pub name: String,
453 pub kind: u32,
455 #[serde(skip_serializing_if = "Option::is_none")]
457 pub detail: Option<String>,
458 pub uri: String,
460 pub range: Range,
462 #[serde(rename = "selectionRange")]
469 pub selection_range: Range,
470 #[serde(skip_serializing_if = "Option::is_none")]
472 pub data: Option<serde_json::Value>,
473}
474
475#[derive(Debug, Clone, Serialize, Deserialize)]
477pub struct CallHierarchyPrepareResult {
478 pub items: Vec<CallHierarchyItemResult>,
480}
481
482#[derive(Debug, Clone, Serialize, Deserialize)]
484pub struct IncomingCall {
485 pub from: CallHierarchyItemResult,
487 pub from_ranges: Vec<Range>,
489}
490
491#[derive(Debug, Clone, Serialize, Deserialize)]
493pub struct IncomingCallsResult {
494 pub calls: Vec<IncomingCall>,
496}
497
498#[derive(Debug, Clone, Serialize, Deserialize)]
500pub struct OutgoingCall {
501 pub to: CallHierarchyItemResult,
503 pub from_ranges: Vec<Range>,
505}
506
507#[derive(Debug, Clone, Serialize, Deserialize)]
509pub struct OutgoingCallsResult {
510 pub calls: Vec<OutgoingCall>,
512}
513
514#[derive(Debug, Clone, Serialize, Deserialize)]
516pub struct ServerLogsResult {
517 pub logs: Vec<crate::bridge::notifications::LogEntry>,
519}
520
521#[derive(Debug, Clone, Serialize, Deserialize)]
523pub struct ServerMessagesResult {
524 pub messages: Vec<crate::bridge::notifications::ServerMessage>,
526}
527
528#[derive(Debug, Clone, Serialize, Deserialize)]
530pub struct SignatureParameter {
531 pub label: String,
533 #[serde(skip_serializing_if = "Option::is_none")]
535 pub documentation: Option<String>,
536}
537
538#[derive(Debug, Clone, Serialize, Deserialize)]
540pub struct SignatureInfo {
541 pub label: String,
543 #[serde(skip_serializing_if = "Option::is_none")]
545 pub documentation: Option<String>,
546 pub parameters: Vec<SignatureParameter>,
548}
549
550#[derive(Debug, Clone, Serialize, Deserialize)]
552pub struct SignatureHelpResult {
553 pub signatures: Vec<SignatureInfo>,
555 #[serde(skip_serializing_if = "Option::is_none")]
557 pub active_signature: Option<u32>,
558 #[serde(skip_serializing_if = "Option::is_none")]
560 pub active_parameter: Option<u32>,
561}
562
563#[derive(Debug, Clone, Serialize, Deserialize)]
565pub struct LocationsResult {
566 pub locations: Vec<Location>,
568}
569
570#[derive(Debug, Clone, Serialize, Deserialize)]
572pub struct InlayHintEntry {
573 pub position: Position2D,
575 pub label: String,
577 #[serde(skip_serializing_if = "Option::is_none")]
579 pub kind: Option<u8>,
580 #[serde(skip_serializing_if = "Option::is_none")]
582 pub padding_left: Option<bool>,
583 #[serde(skip_serializing_if = "Option::is_none")]
585 pub padding_right: Option<bool>,
586 #[serde(skip_serializing_if = "Option::is_none")]
588 pub tooltip: Option<String>,
589}
590
591#[derive(Debug, Clone, Serialize, Deserialize)]
593pub struct InlayHintsResult {
594 pub hints: Vec<InlayHintEntry>,
596}
597
598const MAX_POSITION_VALUE: u32 = 1_000_000;
600const MAX_RANGE_LINES: u32 = 10_000;
602
603pub fn validate_path_against_roots(path: &Path, workspace_roots: &[PathBuf]) -> Result<PathBuf> {
615 let canonical = path.canonicalize().map_err(|e| Error::FileIo {
616 path: path.to_path_buf(),
617 source: e,
618 })?;
619
620 if workspace_roots.is_empty() {
622 return Ok(canonical);
623 }
624
625 for root in workspace_roots {
627 if let Ok(canonical_root) = root.canonicalize()
628 && canonical.starts_with(&canonical_root)
629 {
630 return Ok(canonical);
631 }
632 }
633
634 Err(Error::PathOutsideWorkspace(path.to_path_buf()))
635}
636
637impl Translator {
638 pub(crate) fn validate_path(&self, path: &Path) -> Result<PathBuf> {
644 validate_path_against_roots(path, &self.workspace_roots)
645 }
646
647 fn get_client_for_file(&self, path: &Path, tool: ToolKind) -> Result<(ServerId, LspClient)> {
660 let language = detect_language(path, &self.extension_map);
661 let mut candidates: Vec<&str> = vec![language.as_str()];
662 if let Some(base) = base_language_id(&language) {
663 candidates.push(base);
664 }
665
666 for lang in &candidates {
667 let resolved = lock_std(&self.router).resolve(lang, tool).cloned();
668 let Some(id) = resolved else { continue };
669
670 let found = lock_std(&self.lsp_clients).get(&id).cloned();
671 if let Some(client) = found {
672 return Ok((id, client));
673 }
674 if lock_std(&self.expected_servers).contains(&id) {
678 return Err(Error::ServerInitializing { server_id: id });
679 }
680 tracing::error!(
687 "router route names server '{id}' for tool '{tool}' that is neither \
688 registered nor expected"
689 );
690 return Err(Error::NoServerForTool {
691 language_id: (*lang).to_string(),
692 tool,
693 });
694 }
695
696 let has_language = {
697 let router = lock_std(&self.router);
698 candidates.iter().any(|lang| router.has_language(lang))
699 };
700 if has_language {
701 Err(Error::NoServerForTool {
702 language_id: language,
703 tool,
704 })
705 } else {
706 Err(Error::NoServerForLanguage(language))
707 }
708 }
709
710 async fn prepare_document(
728 &self,
729 file_path: &str,
730 tool: ToolKind,
731 ) -> Result<(LspClient, lsp_types::Uri)> {
732 let path = PathBuf::from(file_path);
733 let validated_path = self.validate_path(&path)?;
734 let (server_id, client) = self.get_client_for_file(&validated_path, tool)?;
735 let uri = self
736 .document_tracker
737 .ensure_open(&validated_path, &server_id, &client)
738 .await?;
739 Ok((client, uri))
740 }
741
742 fn parse_file_uri(&self, uri: &lsp_types::Uri) -> Result<PathBuf> {
750 let uri_str = uri.as_str();
751
752 if !uri_str.starts_with("file://") {
754 return Err(Error::InvalidToolParams(format!(
755 "Invalid URI scheme, expected file:// but got: {uri_str}"
756 )));
757 }
758
759 let path_str = &uri_str["file://".len()..];
761
762 #[cfg(windows)]
765 let path_str = if path_str.len() >= 3
766 && path_str.starts_with('/')
767 && path_str.chars().nth(2) == Some(':')
768 {
769 &path_str[1..]
770 } else {
771 path_str
772 };
773
774 let path = PathBuf::from(path_str);
775
776 self.validate_path(&path)
778 }
779
780 pub async fn handle_hover(
786 &self,
787 file_path: String,
788 line: u32,
789 character: u32,
790 ) -> Result<HoverResult> {
791 let (client, uri) = self.prepare_document(&file_path, ToolKind::Hover).await?;
792 let lsp_position = mcp_to_lsp_position(line, character);
793
794 let params = LspHoverParams {
795 text_document_position_params: TextDocumentPositionParams {
796 text_document: TextDocumentIdentifier { uri },
797 position: lsp_position,
798 },
799 work_done_progress_params: WorkDoneProgressParams::default(),
800 };
801
802 let timeout_duration = Duration::from_secs(30);
803 let response: Option<Hover> = client
804 .request("textDocument/hover", params, timeout_duration)
805 .await?;
806
807 let result = match response {
808 Some(hover) => {
809 let contents = extract_hover_contents(hover.contents);
810 let range = hover.range.map(normalize_range);
811 HoverResult { contents, range }
812 }
813 None => HoverResult {
814 contents: "No hover information available".to_string(),
815 range: None,
816 },
817 };
818
819 Ok(result)
820 }
821
822 pub async fn handle_definition(
828 &self,
829 file_path: String,
830 line: u32,
831 character: u32,
832 ) -> Result<DefinitionResult> {
833 let (client, uri) = self
834 .prepare_document(&file_path, ToolKind::Definition)
835 .await?;
836 let lsp_position = mcp_to_lsp_position(line, character);
837
838 let params = GotoDefinitionParams {
839 text_document_position_params: TextDocumentPositionParams {
840 text_document: TextDocumentIdentifier { uri },
841 position: lsp_position,
842 },
843 work_done_progress_params: WorkDoneProgressParams::default(),
844 partial_result_params: PartialResultParams::default(),
845 };
846
847 let timeout_duration = Duration::from_secs(30);
848 let response: Option<lsp_types::GotoDefinitionResponse> = client
849 .request("textDocument/definition", params, timeout_duration)
850 .await?;
851
852 let locations = match response {
853 Some(lsp_types::GotoDefinitionResponse::Scalar(loc)) => vec![loc],
854 Some(lsp_types::GotoDefinitionResponse::Array(locs)) => locs,
855 Some(lsp_types::GotoDefinitionResponse::Link(links)) => links
856 .into_iter()
857 .map(|link| lsp_types::Location {
858 uri: link.target_uri,
859 range: link.target_selection_range,
860 })
861 .collect(),
862 None => vec![],
863 };
864
865 let result = DefinitionResult {
866 locations: locations
867 .into_iter()
868 .map(|loc| Location {
869 uri: loc.uri.to_string(),
870 range: normalize_range(loc.range),
871 })
872 .collect(),
873 };
874
875 Ok(result)
876 }
877
878 pub async fn handle_references(
884 &self,
885 file_path: String,
886 line: u32,
887 character: u32,
888 include_declaration: bool,
889 ) -> Result<ReferencesResult> {
890 let (client, uri) = self
891 .prepare_document(&file_path, ToolKind::References)
892 .await?;
893 let lsp_position = mcp_to_lsp_position(line, character);
894
895 let params = ReferenceParams {
896 text_document_position: TextDocumentPositionParams {
897 text_document: TextDocumentIdentifier { uri },
898 position: lsp_position,
899 },
900 work_done_progress_params: WorkDoneProgressParams::default(),
901 partial_result_params: PartialResultParams::default(),
902 context: ReferenceContext {
903 include_declaration,
904 },
905 };
906
907 let timeout_duration = Duration::from_secs(30);
908 let response: Option<Vec<lsp_types::Location>> = client
909 .request("textDocument/references", params, timeout_duration)
910 .await?;
911
912 let locations = response.unwrap_or_default();
913
914 let result = ReferencesResult {
915 locations: locations
916 .into_iter()
917 .map(|loc| Location {
918 uri: loc.uri.to_string(),
919 range: normalize_range(loc.range),
920 })
921 .collect(),
922 };
923
924 Ok(result)
925 }
926
927 pub async fn handle_diagnostics(
954 &self,
955 file_path: String,
956 notification_cache: &Mutex<NotificationCache>,
957 ) -> Result<DiagnosticsResult> {
958 let (client, uri) = self
959 .prepare_document(&file_path, ToolKind::Diagnostics)
960 .await?;
961
962 let params = diagnostic_request_params(TextDocumentIdentifier { uri: uri.clone() });
963
964 let timeout_duration = Duration::from_secs(30);
965 let pull_response: Result<lsp_types::DocumentDiagnosticReportResult> = client
966 .request("textDocument/diagnostic", params, timeout_duration)
967 .await;
968
969 let diag_info = {
970 let cache = notification_cache.lock().await;
971 cache.get_diagnostics(uri.as_str()).cloned()
972 };
973
974 match pull_response {
975 Ok(response) => {
976 let items = match response {
977 lsp_types::DocumentDiagnosticReportResult::Report(report) => match report {
978 lsp_types::DocumentDiagnosticReport::Full(full) => {
979 full.full_document_diagnostic_report.items
980 }
981 lsp_types::DocumentDiagnosticReport::Unchanged(_) => vec![],
982 },
983 lsp_types::DocumentDiagnosticReportResult::Partial(_) => vec![],
984 };
985 let pull = DiagnosticsResult {
986 diagnostics: items.iter().map(diagnostic_to_mcp).collect(),
987 };
988 Ok(Self::merge_diagnostics(pull, diag_info.as_ref()))
989 }
990 Err(e) => {
991 let cache_only = Self::diagnostics_from_cache_entry(diag_info.as_ref());
992 if cache_only.diagnostics.is_empty() {
993 Err(e)
994 } else {
995 Ok(cache_only)
996 }
997 }
998 }
999 }
1000
1001 pub async fn handle_rename(
1007 &self,
1008 file_path: String,
1009 line: u32,
1010 character: u32,
1011 new_name: String,
1012 ) -> Result<RenameResult> {
1013 let (client, uri) = self.prepare_document(&file_path, ToolKind::Rename).await?;
1014 let lsp_position = mcp_to_lsp_position(line, character);
1015
1016 let params = LspRenameParams {
1017 text_document_position: TextDocumentPositionParams {
1018 text_document: TextDocumentIdentifier { uri },
1019 position: lsp_position,
1020 },
1021 new_name,
1022 work_done_progress_params: WorkDoneProgressParams::default(),
1023 };
1024
1025 let timeout_duration = Duration::from_secs(30);
1026 let response: Option<WorkspaceEdit> = client
1027 .request("textDocument/rename", params, timeout_duration)
1028 .await?;
1029
1030 let changes = if let Some(edit) = response {
1031 let mut result_changes = Vec::new();
1032
1033 if let Some(changes_map) = edit.changes {
1035 for (uri, edits) in changes_map {
1036 result_changes.push(DocumentChanges {
1037 uri: uri.to_string(),
1038 edits: edits
1039 .into_iter()
1040 .map(|e| TextEdit {
1041 range: normalize_range(e.range),
1042 new_text: e.new_text,
1043 })
1044 .collect(),
1045 });
1046 }
1047 }
1048
1049 if result_changes.is_empty() {
1051 let text_doc_edits = match edit.document_changes {
1052 Some(lsp_types::DocumentChanges::Edits(edits)) => edits,
1053 Some(lsp_types::DocumentChanges::Operations(ops)) => ops
1054 .into_iter()
1055 .filter_map(|op| match op {
1056 lsp_types::DocumentChangeOperation::Edit(e) => Some(e),
1057 lsp_types::DocumentChangeOperation::Op(_) => None,
1058 })
1059 .collect(),
1060 None => vec![],
1061 };
1062 for tde in text_doc_edits {
1063 result_changes.push(DocumentChanges {
1064 uri: tde.text_document.uri.to_string(),
1065 edits: tde
1066 .edits
1067 .into_iter()
1068 .map(|one_of| match one_of {
1069 lsp_types::OneOf::Left(te) => TextEdit {
1070 range: normalize_range(te.range),
1071 new_text: te.new_text,
1072 },
1073 lsp_types::OneOf::Right(ate) => TextEdit {
1074 range: normalize_range(ate.text_edit.range),
1075 new_text: ate.text_edit.new_text,
1076 },
1077 })
1078 .collect(),
1079 });
1080 }
1081 }
1082
1083 result_changes
1084 } else {
1085 vec![]
1086 };
1087
1088 Ok(RenameResult { changes })
1089 }
1090
1091 pub async fn handle_completions(
1097 &self,
1098 file_path: String,
1099 line: u32,
1100 character: u32,
1101 trigger: Option<String>,
1102 ) -> Result<CompletionsResult> {
1103 let (client, uri) = self
1104 .prepare_document(&file_path, ToolKind::Completions)
1105 .await?;
1106 let lsp_position = mcp_to_lsp_position(line, character);
1107
1108 let context = trigger.map(|trigger_char| lsp_types::CompletionContext {
1109 trigger_kind: CompletionTriggerKind::TRIGGER_CHARACTER,
1110 trigger_character: Some(trigger_char),
1111 });
1112
1113 let params = CompletionParams {
1114 text_document_position: TextDocumentPositionParams {
1115 text_document: TextDocumentIdentifier { uri },
1116 position: lsp_position,
1117 },
1118 work_done_progress_params: WorkDoneProgressParams::default(),
1119 partial_result_params: PartialResultParams::default(),
1120 context,
1121 };
1122
1123 let timeout_duration = Duration::from_secs(10);
1124 let response: Option<lsp_types::CompletionResponse> = client
1125 .request("textDocument/completion", params, timeout_duration)
1126 .await?;
1127
1128 let items = match response {
1129 Some(lsp_types::CompletionResponse::Array(items)) => items,
1130 Some(lsp_types::CompletionResponse::List(list)) => list.items,
1131 None => vec![],
1132 };
1133
1134 let result = CompletionsResult {
1135 items: items
1136 .into_iter()
1137 .map(|item| Completion {
1138 label: item.label,
1139 kind: item.kind.map(|k| format!("{k:?}")),
1140 detail: item.detail,
1141 documentation: item.documentation.map(|doc| match doc {
1142 lsp_types::Documentation::String(s) => s,
1143 lsp_types::Documentation::MarkupContent(m) => m.value,
1144 }),
1145 })
1146 .collect(),
1147 };
1148
1149 Ok(result)
1150 }
1151
1152 pub async fn handle_document_symbols(
1158 &self,
1159 file_path: String,
1160 ) -> Result<DocumentSymbolsResult> {
1161 let (client, uri) = self
1162 .prepare_document(&file_path, ToolKind::DocumentSymbols)
1163 .await?;
1164
1165 let params = DocumentSymbolParams {
1166 text_document: TextDocumentIdentifier { uri },
1167 work_done_progress_params: WorkDoneProgressParams::default(),
1168 partial_result_params: PartialResultParams::default(),
1169 };
1170
1171 let timeout_duration = Duration::from_secs(30);
1172 let response: Option<lsp_types::DocumentSymbolResponse> = client
1173 .request("textDocument/documentSymbol", params, timeout_duration)
1174 .await?;
1175
1176 let symbols = match response {
1177 Some(lsp_types::DocumentSymbolResponse::Flat(symbols)) => symbols
1178 .into_iter()
1179 .map(|sym| Symbol {
1180 name: sym.name,
1181 kind: format!("{:?}", sym.kind),
1182 range: normalize_range(sym.location.range),
1183 selection_range: normalize_range(sym.location.range),
1184 children: None,
1185 })
1186 .collect(),
1187 Some(lsp_types::DocumentSymbolResponse::Nested(symbols)) => {
1188 symbols.into_iter().map(convert_document_symbol).collect()
1189 }
1190 None => vec![],
1191 };
1192
1193 Ok(DocumentSymbolsResult { symbols })
1194 }
1195
1196 pub async fn handle_format_document(
1202 &self,
1203 file_path: String,
1204 tab_size: u32,
1205 insert_spaces: bool,
1206 ) -> Result<FormatDocumentResult> {
1207 let (client, uri) = self
1208 .prepare_document(&file_path, ToolKind::FormatDocument)
1209 .await?;
1210
1211 let params = DocumentFormattingParams {
1212 text_document: TextDocumentIdentifier { uri },
1213 options: FormattingOptions {
1214 tab_size,
1215 insert_spaces,
1216 ..Default::default()
1217 },
1218 work_done_progress_params: WorkDoneProgressParams::default(),
1219 };
1220
1221 let timeout_duration = Duration::from_secs(30);
1222 let response: Option<Vec<lsp_types::TextEdit>> = client
1223 .request("textDocument/formatting", params, timeout_duration)
1224 .await?;
1225
1226 let edits = response.unwrap_or_default();
1227
1228 let result = FormatDocumentResult {
1229 edits: edits
1230 .into_iter()
1231 .map(|edit| TextEdit {
1232 range: normalize_range(edit.range),
1233 new_text: edit.new_text,
1234 })
1235 .collect(),
1236 };
1237
1238 Ok(result)
1239 }
1240
1241 pub async fn handle_workspace_symbol(
1247 &self,
1248 query: String,
1249 kind_filter: Option<String>,
1250 limit: u32,
1251 ) -> Result<WorkspaceSymbolResult> {
1252 const MAX_QUERY_LENGTH: usize = 1000;
1253 const VALID_SYMBOL_KINDS: &[&str] = &[
1254 "File",
1255 "Module",
1256 "Namespace",
1257 "Package",
1258 "Class",
1259 "Method",
1260 "Property",
1261 "Field",
1262 "Constructor",
1263 "Enum",
1264 "Interface",
1265 "Function",
1266 "Variable",
1267 "Constant",
1268 "String",
1269 "Number",
1270 "Boolean",
1271 "Array",
1272 "Object",
1273 "Key",
1274 "Null",
1275 "EnumMember",
1276 "Struct",
1277 "Event",
1278 "Operator",
1279 "TypeParameter",
1280 ];
1281
1282 if query.len() > MAX_QUERY_LENGTH {
1284 return Err(Error::InvalidToolParams(format!(
1285 "Query too long: {} chars (max {MAX_QUERY_LENGTH})",
1286 query.len()
1287 )));
1288 }
1289
1290 if let Some(ref kind) = kind_filter
1292 && !VALID_SYMBOL_KINDS
1293 .iter()
1294 .any(|k| k.eq_ignore_ascii_case(kind))
1295 {
1296 return Err(Error::InvalidToolParams(format!(
1297 "Invalid kind_filter: '{kind}'. Valid values: {VALID_SYMBOL_KINDS:?}"
1298 )));
1299 }
1300
1301 let server_id = lock_std(&self.router)
1306 .resolve_any(ToolKind::WorkspaceSymbols)
1307 .cloned()
1308 .ok_or(Error::NoServerConfigured)?;
1309 let client = lock_std(&self.lsp_clients).get(&server_id).cloned();
1310 let client = client.ok_or_else(|| {
1311 if lock_std(&self.expected_servers).contains(&server_id) {
1312 Error::ServerInitializing { server_id }
1313 } else {
1314 Error::NoServerConfigured
1315 }
1316 })?;
1317
1318 let params = LspWorkspaceSymbolParams {
1319 query,
1320 work_done_progress_params: WorkDoneProgressParams::default(),
1321 partial_result_params: PartialResultParams::default(),
1322 };
1323
1324 let timeout_duration = Duration::from_secs(30);
1325 let response: Option<Vec<lsp_types::SymbolInformation>> = client
1326 .request("workspace/symbol", params, timeout_duration)
1327 .await?;
1328
1329 let mut symbols: Vec<WorkspaceSymbol> = response
1330 .unwrap_or_default()
1331 .into_iter()
1332 .map(|sym| WorkspaceSymbol {
1333 name: sym.name,
1334 kind: format!("{:?}", sym.kind),
1335 location: Location {
1336 uri: sym.location.uri.to_string(),
1337 range: normalize_range(sym.location.range),
1338 },
1339 container_name: sym.container_name,
1340 })
1341 .collect();
1342
1343 if let Some(kind) = kind_filter {
1345 symbols.retain(|s| s.kind.eq_ignore_ascii_case(&kind));
1346 }
1347
1348 symbols.truncate(limit as usize);
1350
1351 Ok(WorkspaceSymbolResult { symbols })
1352 }
1353
1354 pub async fn handle_code_actions(
1360 &self,
1361 file_path: String,
1362 start_line: u32,
1363 start_character: u32,
1364 end_line: u32,
1365 end_character: u32,
1366 kind_filter: Option<String>,
1367 ) -> Result<CodeActionsResult> {
1368 validate_code_action_params(
1369 start_line,
1370 start_character,
1371 end_line,
1372 end_character,
1373 kind_filter.as_deref(),
1374 )?;
1375
1376 let (client, uri) = self
1377 .prepare_document(&file_path, ToolKind::CodeActions)
1378 .await?;
1379
1380 let range = lsp_types::Range {
1381 start: mcp_to_lsp_position(start_line, start_character),
1382 end: mcp_to_lsp_position(end_line, end_character),
1383 };
1384
1385 let only = kind_filter.map(|k| vec![lsp_types::CodeActionKind::from(k)]);
1387
1388 let context_diagnostics: Vec<lsp_types::Diagnostic> = vec![];
1393
1394 let params = lsp_types::CodeActionParams {
1395 text_document: TextDocumentIdentifier { uri },
1396 range,
1397 context: lsp_types::CodeActionContext {
1398 diagnostics: context_diagnostics,
1399 only,
1400 trigger_kind: Some(lsp_types::CodeActionTriggerKind::INVOKED),
1401 },
1402 work_done_progress_params: WorkDoneProgressParams::default(),
1403 partial_result_params: PartialResultParams::default(),
1404 };
1405
1406 let timeout_duration = Duration::from_secs(30);
1407 let response: Option<lsp_types::CodeActionResponse> = client
1408 .request("textDocument/codeAction", params, timeout_duration)
1409 .await?;
1410 let response_vec = response.unwrap_or_default();
1411 let mut actions = Vec::with_capacity(response_vec.len());
1412
1413 for action_or_command in response_vec {
1414 let action = match action_or_command {
1415 lsp_types::CodeActionOrCommand::CodeAction(action) => convert_code_action(action),
1416 lsp_types::CodeActionOrCommand::Command(cmd) => {
1417 let arguments = cmd.arguments.unwrap_or_else(Vec::new);
1418 CodeAction {
1419 title: cmd.title.clone(),
1420 kind: None,
1421 diagnostics: Vec::new(),
1422 edit: None,
1423 command: Some(CommandDescription {
1424 title: cmd.title,
1425 command: cmd.command,
1426 arguments,
1427 }),
1428 is_preferred: false,
1429 }
1430 }
1431 };
1432 actions.push(action);
1433 }
1434
1435 Ok(CodeActionsResult { actions })
1436 }
1437
1438 pub async fn handle_call_hierarchy_prepare(
1444 &self,
1445 file_path: String,
1446 line: u32,
1447 character: u32,
1448 ) -> Result<CallHierarchyPrepareResult> {
1449 if line < 1 || character < 1 {
1451 return Err(Error::InvalidToolParams(
1452 "Line and character positions must be >= 1".to_string(),
1453 ));
1454 }
1455
1456 if line > MAX_POSITION_VALUE || character > MAX_POSITION_VALUE {
1457 return Err(Error::InvalidToolParams(format!(
1458 "Position values must be <= {MAX_POSITION_VALUE}"
1459 )));
1460 }
1461
1462 let (client, uri) = self
1463 .prepare_document(&file_path, ToolKind::CallHierarchy)
1464 .await?;
1465 let lsp_position = mcp_to_lsp_position(line, character);
1466
1467 let params = LspCallHierarchyPrepareParams {
1468 text_document_position_params: TextDocumentPositionParams {
1469 text_document: TextDocumentIdentifier { uri },
1470 position: lsp_position,
1471 },
1472 work_done_progress_params: WorkDoneProgressParams::default(),
1473 };
1474
1475 let timeout_duration = Duration::from_secs(30);
1476 let response: Option<Vec<CallHierarchyItem>> = client
1477 .request(
1478 "textDocument/prepareCallHierarchy",
1479 params,
1480 timeout_duration,
1481 )
1482 .await?;
1483
1484 let lsp_items = response.unwrap_or_default();
1486 let mut items = Vec::with_capacity(lsp_items.len());
1487 for item in lsp_items {
1488 items.push(convert_call_hierarchy_item(item));
1489 }
1490
1491 Ok(CallHierarchyPrepareResult { items })
1492 }
1493
1494 pub async fn handle_incoming_calls(
1500 &self,
1501 item: serde_json::Value,
1502 ) -> Result<IncomingCallsResult> {
1503 let lsp_item = mcp_item_to_lsp(item)?;
1505
1506 let path = self.parse_file_uri(&lsp_item.uri)?;
1512 let (_server_id, client) = self.get_client_for_file(&path, ToolKind::CallHierarchy)?;
1513
1514 let params = CallHierarchyIncomingCallsParams {
1515 item: lsp_item,
1516 work_done_progress_params: WorkDoneProgressParams::default(),
1517 partial_result_params: PartialResultParams::default(),
1518 };
1519
1520 let timeout_duration = Duration::from_secs(30);
1521 let response: Option<Vec<CallHierarchyIncomingCall>> = client
1522 .request("callHierarchy/incomingCalls", params, timeout_duration)
1523 .await?;
1524
1525 let lsp_calls = response.unwrap_or_default();
1527 let mut calls = Vec::with_capacity(lsp_calls.len());
1528
1529 for call in lsp_calls {
1530 let from_ranges = {
1531 let mut ranges = Vec::with_capacity(call.from_ranges.len());
1532 for range in call.from_ranges {
1533 ranges.push(normalize_range(range));
1534 }
1535 ranges
1536 };
1537
1538 calls.push(IncomingCall {
1539 from: convert_call_hierarchy_item(call.from),
1540 from_ranges,
1541 });
1542 }
1543
1544 Ok(IncomingCallsResult { calls })
1545 }
1546
1547 pub async fn handle_outgoing_calls(
1553 &self,
1554 item: serde_json::Value,
1555 ) -> Result<OutgoingCallsResult> {
1556 let lsp_item = mcp_item_to_lsp(item)?;
1558
1559 let path = self.parse_file_uri(&lsp_item.uri)?;
1562 let (_server_id, client) = self.get_client_for_file(&path, ToolKind::CallHierarchy)?;
1563
1564 let params = CallHierarchyOutgoingCallsParams {
1565 item: lsp_item,
1566 work_done_progress_params: WorkDoneProgressParams::default(),
1567 partial_result_params: PartialResultParams::default(),
1568 };
1569
1570 let timeout_duration = Duration::from_secs(30);
1571 let response: Option<Vec<CallHierarchyOutgoingCall>> = client
1572 .request("callHierarchy/outgoingCalls", params, timeout_duration)
1573 .await?;
1574
1575 let lsp_calls = response.unwrap_or_default();
1577 let mut calls = Vec::with_capacity(lsp_calls.len());
1578
1579 for call in lsp_calls {
1580 let from_ranges = {
1581 let mut ranges = Vec::with_capacity(call.from_ranges.len());
1582 for range in call.from_ranges {
1583 ranges.push(normalize_range(range));
1584 }
1585 ranges
1586 };
1587
1588 calls.push(OutgoingCall {
1589 to: convert_call_hierarchy_item(call.to),
1590 from_ranges,
1591 });
1592 }
1593
1594 Ok(OutgoingCallsResult { calls })
1595 }
1596
1597 pub fn cached_diagnostics_uri(workspace_roots: &[PathBuf], file_path: &str) -> Result<String> {
1610 let path = PathBuf::from(file_path);
1611 let validated_path = validate_path_against_roots(&path, workspace_roots)?;
1612
1613 Ok(path_to_uri(&validated_path).to_string())
1616 }
1617
1618 #[must_use]
1625 pub fn diagnostics_from_cache_entry(diag_info: Option<&DiagnosticInfo>) -> DiagnosticsResult {
1626 let diagnostics = diag_info.map_or_else(Vec::new, |diag_info| {
1627 diag_info
1628 .diagnostics
1629 .iter()
1630 .map(diagnostic_to_mcp)
1631 .collect()
1632 });
1633
1634 DiagnosticsResult { diagnostics }
1635 }
1636
1637 #[must_use]
1673 pub fn merge_diagnostics(
1674 mut pull: DiagnosticsResult,
1675 diag_info: Option<&DiagnosticInfo>,
1676 ) -> DiagnosticsResult {
1677 const DUPLICATE_RANGE_PROXIMITY_LINES: u32 = 3;
1685
1686 fn position_le(a: &Position2D, b: &Position2D) -> bool {
1687 (a.line, a.character) <= (b.line, b.character)
1688 }
1689
1690 fn ranges_close(a: &Range, b: &Range) -> bool {
1691 let overlaps = position_le(&a.start, &b.end) && position_le(&b.start, &a.end);
1692 overlaps || a.start.line.abs_diff(b.start.line) <= DUPLICATE_RANGE_PROXIMITY_LINES
1693 }
1694
1695 fn is_duplicate(pull: &[Diagnostic], candidate: &Diagnostic) -> bool {
1696 pull.iter().any(|p| match (&candidate.code, &p.code) {
1697 (Some(c), Some(pc)) if c == pc && p.severity == candidate.severity => {
1698 ranges_close(&p.range, &candidate.range)
1699 }
1700 _ => p == candidate,
1701 })
1702 }
1703
1704 let cached = Self::diagnostics_from_cache_entry(diag_info).diagnostics;
1705 let new_diagnostics: Vec<_> = cached
1706 .into_iter()
1707 .filter(|c| !is_duplicate(&pull.diagnostics, c))
1708 .collect();
1709 pull.diagnostics.extend(new_diagnostics);
1710 pull.diagnostics
1711 .sort_by_key(|d| (d.range.start.line, d.range.start.character));
1712 pull
1713 }
1714
1715 pub fn handle_server_logs(
1721 cache: &NotificationCache,
1722 limit: usize,
1723 min_level: Option<String>,
1724 ) -> Result<ServerLogsResult> {
1725 use crate::bridge::notifications::LogLevel;
1726
1727 let min_level_filter = if let Some(level_str) = min_level {
1728 let level = match level_str.to_lowercase().as_str() {
1729 "error" => LogLevel::Error,
1730 "warning" => LogLevel::Warning,
1731 "info" => LogLevel::Info,
1732 "debug" => LogLevel::Debug,
1733 _ => {
1734 return Err(Error::InvalidToolParams(format!(
1735 "Invalid min_level: '{level_str}'. Valid values: error, warning, info, debug"
1736 )));
1737 }
1738 };
1739 Some(level)
1740 } else {
1741 None
1742 };
1743
1744 let all_logs = cache.get_logs();
1745
1746 let logs: Vec<_> = all_logs
1747 .iter()
1748 .filter(|log| {
1749 min_level_filter.is_none_or(|min| match min {
1750 LogLevel::Error => matches!(log.level, LogLevel::Error),
1751 LogLevel::Warning => matches!(log.level, LogLevel::Error | LogLevel::Warning),
1752 LogLevel::Info => !matches!(log.level, LogLevel::Debug),
1753 LogLevel::Debug => true,
1754 })
1755 })
1756 .take(limit)
1757 .cloned()
1758 .collect();
1759
1760 Ok(ServerLogsResult { logs })
1761 }
1762
1763 pub fn handle_server_messages(
1769 cache: &NotificationCache,
1770 limit: usize,
1771 ) -> Result<ServerMessagesResult> {
1772 let all_messages = cache.get_messages();
1773 let messages: Vec<_> = all_messages.iter().take(limit).cloned().collect();
1774 Ok(ServerMessagesResult { messages })
1775 }
1776
1777 pub async fn handle_signature_help(
1786 &self,
1787 file_path: String,
1788 line: u32,
1789 character: u32,
1790 ) -> Result<SignatureHelpResult> {
1791 let (client, uri) = self
1792 .prepare_document(&file_path, ToolKind::SignatureHelp)
1793 .await?;
1794 let lsp_position = mcp_to_lsp_position(line, character);
1795
1796 let params = LspSignatureHelpParams {
1797 text_document_position_params: TextDocumentPositionParams {
1798 text_document: TextDocumentIdentifier { uri },
1799 position: lsp_position,
1800 },
1801 work_done_progress_params: WorkDoneProgressParams::default(),
1802 context: None,
1803 };
1804
1805 let timeout_duration = Duration::from_secs(30);
1806 let response: Option<lsp_types::SignatureHelp> = client
1807 .request("textDocument/signatureHelp", params, timeout_duration)
1808 .await?;
1809
1810 let result = match response {
1811 Some(sig_help) => SignatureHelpResult {
1812 signatures: sig_help
1813 .signatures
1814 .into_iter()
1815 .map(|sig| SignatureInfo {
1816 label: sig.label,
1817 documentation: sig.documentation.map(extract_documentation),
1818 parameters: sig
1819 .parameters
1820 .unwrap_or_default()
1821 .into_iter()
1822 .map(|p| SignatureParameter {
1823 label: match p.label {
1824 lsp_types::ParameterLabel::Simple(s) => s,
1825 lsp_types::ParameterLabel::LabelOffsets([start, end]) => {
1826 format!("[{start},{end}]")
1827 }
1828 },
1829 documentation: p.documentation.map(extract_documentation),
1830 })
1831 .collect(),
1832 })
1833 .collect(),
1834 active_signature: sig_help.active_signature,
1835 active_parameter: sig_help.active_parameter,
1836 },
1837 None => SignatureHelpResult {
1838 signatures: vec![],
1839 active_signature: None,
1840 active_parameter: None,
1841 },
1842 };
1843
1844 Ok(result)
1845 }
1846
1847 pub async fn handle_implementation(
1855 &self,
1856 file_path: String,
1857 line: u32,
1858 character: u32,
1859 ) -> Result<LocationsResult> {
1860 let (client, uri) = self
1861 .prepare_document(&file_path, ToolKind::Implementation)
1862 .await?;
1863 let lsp_position = mcp_to_lsp_position(line, character);
1864
1865 let params = GotoDefinitionParams {
1866 text_document_position_params: TextDocumentPositionParams {
1867 text_document: TextDocumentIdentifier { uri },
1868 position: lsp_position,
1869 },
1870 work_done_progress_params: WorkDoneProgressParams::default(),
1871 partial_result_params: PartialResultParams::default(),
1872 };
1873
1874 let timeout_duration = Duration::from_secs(30);
1875 let response: Option<lsp_types::GotoDefinitionResponse> = client
1876 .request("textDocument/implementation", params, timeout_duration)
1877 .await?;
1878
1879 Ok(LocationsResult {
1880 locations: goto_response_to_locations(response),
1881 })
1882 }
1883
1884 pub async fn handle_type_definition(
1893 &self,
1894 file_path: String,
1895 line: u32,
1896 character: u32,
1897 ) -> Result<LocationsResult> {
1898 let (client, uri) = self
1899 .prepare_document(&file_path, ToolKind::TypeDefinition)
1900 .await?;
1901 let lsp_position = mcp_to_lsp_position(line, character);
1902
1903 let params = GotoDefinitionParams {
1904 text_document_position_params: TextDocumentPositionParams {
1905 text_document: TextDocumentIdentifier { uri },
1906 position: lsp_position,
1907 },
1908 work_done_progress_params: WorkDoneProgressParams::default(),
1909 partial_result_params: PartialResultParams::default(),
1910 };
1911
1912 let timeout_duration = Duration::from_secs(30);
1913 let response: Option<lsp_types::GotoDefinitionResponse> = client
1914 .request("textDocument/typeDefinition", params, timeout_duration)
1915 .await?;
1916
1917 Ok(LocationsResult {
1918 locations: goto_response_to_locations(response),
1919 })
1920 }
1921
1922 pub async fn handle_inlay_hints(
1931 &self,
1932 file_path: String,
1933 start_line: u32,
1934 start_character: u32,
1935 end_line: u32,
1936 end_character: u32,
1937 ) -> Result<InlayHintsResult> {
1938 use crate::bridge::encoding::lsp_to_mcp_position;
1939
1940 let (client, uri) = self
1941 .prepare_document(&file_path, ToolKind::InlayHints)
1942 .await?;
1943
1944 let lsp_start = mcp_to_lsp_position(start_line, start_character);
1945 let lsp_end = mcp_to_lsp_position(end_line, end_character);
1946
1947 let params = InlayHintParams {
1948 text_document: TextDocumentIdentifier { uri },
1949 range: lsp_types::Range {
1950 start: lsp_start,
1951 end: lsp_end,
1952 },
1953 work_done_progress_params: WorkDoneProgressParams::default(),
1954 };
1955
1956 let timeout_duration = Duration::from_secs(30);
1957 let response: Option<Vec<lsp_types::InlayHint>> = client
1958 .request("textDocument/inlayHint", params, timeout_duration)
1959 .await?;
1960
1961 let hints = response
1962 .unwrap_or_default()
1963 .into_iter()
1964 .map(|hint| {
1965 let (mcp_line, mcp_character) = lsp_to_mcp_position(hint.position);
1966 let label = match hint.label {
1967 InlayHintLabel::String(s) => s,
1968 InlayHintLabel::LabelParts(parts) => parts
1969 .into_iter()
1970 .map(|p| p.value)
1971 .collect::<Vec<_>>()
1972 .concat(),
1973 };
1974 let tooltip = hint.tooltip.map(|t| match t {
1975 lsp_types::InlayHintTooltip::String(s) => s,
1976 lsp_types::InlayHintTooltip::MarkupContent(m) => m.value,
1977 });
1978 InlayHintEntry {
1979 position: Position2D {
1980 line: mcp_line,
1981 character: mcp_character,
1982 },
1983 label,
1984 kind: hint.kind.and_then(|k| {
1985 serde_json::to_value(k)
1986 .ok()
1987 .and_then(|v| v.as_i64())
1988 .and_then(|n| u8::try_from(n).ok())
1989 }),
1990 padding_left: hint.padding_left,
1991 padding_right: hint.padding_right,
1992 tooltip,
1993 }
1994 })
1995 .collect();
1996
1997 Ok(InlayHintsResult { hints })
1998 }
1999}
2000
2001fn extract_documentation(doc: lsp_types::Documentation) -> String {
2004 match doc {
2005 lsp_types::Documentation::String(s) => s,
2006 lsp_types::Documentation::MarkupContent(m) => m.value,
2007 }
2008}
2009
2010fn goto_response_to_locations(
2012 response: Option<lsp_types::GotoDefinitionResponse>,
2013) -> Vec<Location> {
2014 let lsp_locs: Vec<lsp_types::Location> = match response {
2015 Some(lsp_types::GotoDefinitionResponse::Scalar(loc)) => vec![loc],
2016 Some(lsp_types::GotoDefinitionResponse::Array(locs)) => locs,
2017 Some(lsp_types::GotoDefinitionResponse::Link(links)) => links
2018 .into_iter()
2019 .map(|link| lsp_types::Location {
2020 uri: link.target_uri,
2021 range: link.target_selection_range,
2022 })
2023 .collect(),
2024 None => vec![],
2025 };
2026
2027 lsp_locs
2028 .into_iter()
2029 .map(|loc| Location {
2030 uri: loc.uri.to_string(),
2031 range: normalize_range(loc.range),
2032 })
2033 .collect()
2034}
2035
2036fn extract_hover_contents(contents: HoverContents) -> String {
2037 match contents {
2038 HoverContents::Scalar(marked_string) => marked_string_to_string(marked_string),
2039 HoverContents::Array(marked_strings) => marked_strings
2040 .into_iter()
2041 .map(marked_string_to_string)
2042 .collect::<Vec<_>>()
2043 .join("\n\n"),
2044 HoverContents::Markup(markup) => markup.value,
2045 }
2046}
2047
2048fn marked_string_to_string(marked: MarkedString) -> String {
2050 match marked {
2051 MarkedString::String(s) => s,
2052 MarkedString::LanguageString(ls) => format!("```{}\n{}\n```", ls.language, ls.value),
2053 }
2054}
2055
2056fn validate_code_action_params(
2059 start_line: u32,
2060 start_character: u32,
2061 end_line: u32,
2062 end_character: u32,
2063 kind_filter: Option<&str>,
2064) -> Result<()> {
2065 const VALID_ACTION_KINDS: &[&str] = &[
2066 "quickfix",
2067 "refactor",
2068 "refactor.extract",
2069 "refactor.inline",
2070 "refactor.rewrite",
2071 "source",
2072 "source.organizeImports",
2073 ];
2074
2075 if let Some(kind) = kind_filter
2076 && !VALID_ACTION_KINDS
2077 .iter()
2078 .any(|k| k.eq_ignore_ascii_case(kind))
2079 {
2080 return Err(Error::InvalidToolParams(format!(
2081 "Invalid kind_filter: '{kind}'. Valid values: {VALID_ACTION_KINDS:?}"
2082 )));
2083 }
2084
2085 if start_line < 1 || start_character < 1 || end_line < 1 || end_character < 1 {
2086 return Err(Error::InvalidToolParams(
2087 "Line and character positions must be >= 1".to_string(),
2088 ));
2089 }
2090
2091 if start_line > MAX_POSITION_VALUE
2092 || start_character > MAX_POSITION_VALUE
2093 || end_line > MAX_POSITION_VALUE
2094 || end_character > MAX_POSITION_VALUE
2095 {
2096 return Err(Error::InvalidToolParams(format!(
2097 "Position values must be <= {MAX_POSITION_VALUE}"
2098 )));
2099 }
2100
2101 if end_line.saturating_sub(start_line) > MAX_RANGE_LINES {
2102 return Err(Error::InvalidToolParams(format!(
2103 "Range size must be <= {MAX_RANGE_LINES} lines"
2104 )));
2105 }
2106
2107 if start_line > end_line || (start_line == end_line && start_character > end_character) {
2108 return Err(Error::InvalidToolParams(
2109 "Start position must be before or equal to end position".to_string(),
2110 ));
2111 }
2112
2113 Ok(())
2114}
2115
2116fn mcp_item_to_lsp(item: serde_json::Value) -> Result<CallHierarchyItem> {
2124 let mcp: CallHierarchyItemResult = serde_json::from_value(item)
2125 .map_err(|e| Error::InvalidToolParams(format!("Invalid call hierarchy item: {e}")))?;
2126
2127 let uri = mcp.uri.parse::<lsp_types::Uri>().map_err(|e| {
2128 Error::InvalidToolParams(format!("Invalid URI in call hierarchy item: {e}"))
2129 })?;
2130
2131 let detail = mcp.detail;
2132 let data = mcp.data;
2133
2134 let kind: lsp_types::SymbolKind = serde_json::from_value(serde_json::json!(mcp.kind))
2137 .unwrap_or(lsp_types::SymbolKind::FUNCTION);
2138
2139 Ok(CallHierarchyItem {
2140 name: mcp.name,
2141 kind,
2142 tags: None,
2143 detail,
2144 uri,
2145 range: denormalize_range(&mcp.range),
2146 selection_range: denormalize_range(&mcp.selection_range),
2147 data,
2148 })
2149}
2150
2151const fn denormalize_range(range: &Range) -> lsp_types::Range {
2156 lsp_types::Range {
2157 start: lsp_types::Position {
2158 line: range.start.line.saturating_sub(1),
2159 character: range.start.character.saturating_sub(1),
2160 },
2161 end: lsp_types::Position {
2162 line: range.end.line.saturating_sub(1),
2163 character: range.end.character.saturating_sub(1),
2164 },
2165 }
2166}
2167
2168const fn normalize_range(range: lsp_types::Range) -> Range {
2169 Range {
2170 start: Position2D {
2171 line: range.start.line + 1,
2172 character: range.start.character + 1,
2173 },
2174 end: Position2D {
2175 line: range.end.line + 1,
2176 character: range.end.character + 1,
2177 },
2178 }
2179}
2180
2181fn diagnostic_to_mcp(diag: &lsp_types::Diagnostic) -> Diagnostic {
2188 Diagnostic {
2189 range: normalize_range(diag.range),
2190 severity: match diag.severity {
2191 Some(lsp_types::DiagnosticSeverity::ERROR) => DiagnosticSeverity::Error,
2192 Some(lsp_types::DiagnosticSeverity::WARNING) => DiagnosticSeverity::Warning,
2193 Some(lsp_types::DiagnosticSeverity::HINT) => DiagnosticSeverity::Hint,
2194 _ => DiagnosticSeverity::Information,
2196 },
2197 message: diag.message.clone(),
2198 code: diag.code.as_ref().map(|c| match c {
2199 lsp_types::NumberOrString::Number(n) => n.to_string(),
2200 lsp_types::NumberOrString::String(s) => s.clone(),
2201 }),
2202 }
2203}
2204
2205fn convert_document_symbol(symbol: DocumentSymbol) -> Symbol {
2207 Symbol {
2208 name: symbol.name,
2209 kind: format!("{:?}", symbol.kind),
2210 range: normalize_range(symbol.range),
2211 selection_range: normalize_range(symbol.selection_range),
2212 children: symbol
2213 .children
2214 .map(|children| children.into_iter().map(convert_document_symbol).collect()),
2215 }
2216}
2217
2218fn convert_call_hierarchy_item(item: CallHierarchyItem) -> CallHierarchyItemResult {
2220 CallHierarchyItemResult {
2221 name: item.name,
2222 kind: serde_json::to_value(item.kind)
2223 .ok()
2224 .and_then(|v| v.as_u64())
2225 .and_then(|n| u32::try_from(n).ok())
2226 .unwrap_or(0),
2227 detail: item.detail,
2228 uri: item.uri.to_string(),
2229 range: normalize_range(item.range),
2230 selection_range: normalize_range(item.selection_range),
2231 data: item.data,
2232 }
2233}
2234
2235fn convert_code_action(action: lsp_types::CodeAction) -> CodeAction {
2237 let diagnostics = action.diagnostics.map_or_else(Vec::new, |diags| {
2238 let mut result = Vec::with_capacity(diags.len());
2239 for d in diags {
2240 result.push(Diagnostic {
2241 range: normalize_range(d.range),
2242 severity: match d.severity {
2243 Some(lsp_types::DiagnosticSeverity::ERROR) => DiagnosticSeverity::Error,
2244 Some(lsp_types::DiagnosticSeverity::WARNING) => DiagnosticSeverity::Warning,
2245 Some(lsp_types::DiagnosticSeverity::INFORMATION) => {
2246 DiagnosticSeverity::Information
2247 }
2248 Some(lsp_types::DiagnosticSeverity::HINT) => DiagnosticSeverity::Hint,
2249 _ => DiagnosticSeverity::Information,
2250 },
2251 message: d.message,
2252 code: d.code.map(|c| match c {
2253 lsp_types::NumberOrString::Number(n) => n.to_string(),
2254 lsp_types::NumberOrString::String(s) => s,
2255 }),
2256 });
2257 }
2258 result
2259 });
2260
2261 let edit = action.edit.map(|edit| {
2262 let changes = edit.changes.map_or_else(Vec::new, |changes_map| {
2263 let mut result = Vec::with_capacity(changes_map.len());
2264 for (uri, edits) in changes_map {
2265 let mut text_edits = Vec::with_capacity(edits.len());
2266 for e in edits {
2267 text_edits.push(TextEdit {
2268 range: normalize_range(e.range),
2269 new_text: e.new_text,
2270 });
2271 }
2272 result.push(DocumentChanges {
2273 uri: uri.to_string(),
2274 edits: text_edits,
2275 });
2276 }
2277 result
2278 });
2279 WorkspaceEditDescription { changes }
2280 });
2281
2282 let command = action.command.map(|cmd| {
2283 let arguments = cmd.arguments.unwrap_or_else(Vec::new);
2284 CommandDescription {
2285 title: cmd.title,
2286 command: cmd.command,
2287 arguments,
2288 }
2289 });
2290
2291 CodeAction {
2292 title: action.title,
2293 kind: action.kind.map(|k| k.as_str().to_string()),
2294 diagnostics,
2295 edit,
2296 command,
2297 is_preferred: action.is_preferred.unwrap_or(false),
2298 }
2299}
2300
2301#[cfg(test)]
2302#[allow(clippy::unwrap_used, clippy::expect_used)]
2303mod tests {
2304 use std::fs;
2305
2306 use tempfile::TempDir;
2307 use url::Url;
2308
2309 use super::*;
2310
2311 #[test]
2312 fn test_translator_new() {
2313 let translator = Translator::new();
2314 assert_eq!(translator.workspace_roots.len(), 0);
2315 assert_eq!(lock_std(&translator.lsp_clients).len(), 0);
2316 assert_eq!(lock_std(&translator.lsp_servers).len(), 0);
2317 }
2318
2319 #[test]
2320 fn test_set_workspace_roots() {
2321 let mut translator = Translator::new();
2322 let roots = vec![PathBuf::from("/test/root1"), PathBuf::from("/test/root2")];
2323 translator.set_workspace_roots(roots.clone());
2324 assert_eq!(*translator.workspace_roots, roots);
2325 }
2326
2327 #[test]
2328 fn test_register_server() {
2329 let translator = Translator::new();
2330
2331 assert_eq!(lock_std(&translator.lsp_servers).len(), 0);
2333
2334 }
2343
2344 #[test]
2345 fn test_get_client_for_file_server_initializing_when_expected() {
2346 let path = PathBuf::from("/ws/Assets/Scripts/Player.cs");
2350 let lang = detect_language(&path, &HashMap::new());
2351 let id = ServerId::from(lang.clone());
2352
2353 let translator = Translator::new().with_router(ToolRouter::catch_all([(id.clone(), lang)]));
2354 let mut expected = HashSet::new();
2355 expected.insert(id.clone());
2356 translator.set_expected_servers(expected);
2357
2358 let err = translator
2359 .get_client_for_file(&path, ToolKind::Hover)
2360 .unwrap_err();
2361 assert!(matches!(err, Error::ServerInitializing { server_id } if server_id == id));
2362 }
2363
2364 #[test]
2365 fn test_get_client_for_file_no_server_when_not_expected() {
2366 let translator = Translator::new();
2369 let path = PathBuf::from("/ws/Assets/Scripts/Player.cs");
2370 let lang = detect_language(&path, &translator.extension_map);
2371
2372 let err = translator
2373 .get_client_for_file(&path, ToolKind::Hover)
2374 .unwrap_err();
2375 assert!(matches!(err, Error::NoServerForLanguage(ref l) if *l == lang));
2376 }
2377
2378 #[test]
2379 fn test_clear_expected_servers_reverts_to_no_server_after_all_routes_dropped() {
2380 let path = PathBuf::from("/ws/Assets/Scripts/Player.cs");
2387 let lang = detect_language(&path, &HashMap::new());
2388 let id = ServerId::from(lang.clone());
2389
2390 let translator = Translator::new().with_router(ToolRouter::catch_all([(id.clone(), lang)]));
2391 let mut expected = HashSet::new();
2392 expected.insert(id);
2393 translator.set_expected_servers(expected);
2394
2395 translator.rebind_router(&HashSet::new());
2396 translator.clear_expected_servers();
2397
2398 let err = translator
2399 .get_client_for_file(&path, ToolKind::Hover)
2400 .unwrap_err();
2401 assert!(matches!(err, Error::NoServerForLanguage(_)));
2402 }
2403
2404 #[test]
2405 fn test_diagnostic_request_params_omit_optional_null_fields() {
2406 let uri = "file:///test.ts".parse().unwrap();
2407 let params = diagnostic_request_params(TextDocumentIdentifier { uri });
2408 let value = serde_json::to_value(params).unwrap();
2409
2410 assert_eq!(value["textDocument"]["uri"], "file:///test.ts");
2411 assert!(value.get("identifier").is_none());
2412 assert!(value.get("previousResultId").is_none());
2413 }
2414
2415 #[test]
2416 fn test_validate_path_no_workspace_roots() {
2417 let translator = Translator::new();
2418 let temp_dir = TempDir::new().unwrap();
2419 let test_file = temp_dir.path().join("test.rs");
2420 fs::write(&test_file, "fn main() {}").unwrap();
2421
2422 let result = translator.validate_path(&test_file);
2424 assert!(result.is_ok());
2425 }
2426
2427 #[test]
2428 fn test_validate_path_within_workspace() {
2429 let mut translator = Translator::new();
2430 let temp_dir = TempDir::new().unwrap();
2431 let workspace_root = temp_dir.path().to_path_buf();
2432 translator.set_workspace_roots(vec![workspace_root]);
2433
2434 let test_file = temp_dir.path().join("test.rs");
2435 fs::write(&test_file, "fn main() {}").unwrap();
2436
2437 let result = translator.validate_path(&test_file);
2438 assert!(result.is_ok());
2439 }
2440
2441 #[test]
2442 fn test_validate_path_outside_workspace() {
2443 let mut translator = Translator::new();
2444 let temp_dir1 = TempDir::new().unwrap();
2445 let temp_dir2 = TempDir::new().unwrap();
2446
2447 translator.set_workspace_roots(vec![temp_dir1.path().to_path_buf()]);
2449
2450 let test_file = temp_dir2.path().join("test.rs");
2452 fs::write(&test_file, "fn main() {}").unwrap();
2453
2454 let result = translator.validate_path(&test_file);
2455 assert!(matches!(result, Err(Error::PathOutsideWorkspace(_))));
2456 }
2457
2458 #[test]
2459 fn test_normalize_range() {
2460 let lsp_range = lsp_types::Range {
2461 start: lsp_types::Position {
2462 line: 0,
2463 character: 0,
2464 },
2465 end: lsp_types::Position {
2466 line: 2,
2467 character: 5,
2468 },
2469 };
2470
2471 let mcp_range = normalize_range(lsp_range);
2472 assert_eq!(mcp_range.start.line, 1);
2473 assert_eq!(mcp_range.start.character, 1);
2474 assert_eq!(mcp_range.end.line, 3);
2475 assert_eq!(mcp_range.end.character, 6);
2476 }
2477
2478 #[test]
2479 fn test_extract_hover_contents_string() {
2480 let marked_string = lsp_types::MarkedString::String("Test hover".to_string());
2481 let contents = lsp_types::HoverContents::Scalar(marked_string);
2482 let result = extract_hover_contents(contents);
2483 assert_eq!(result, "Test hover");
2484 }
2485
2486 #[test]
2487 fn test_extract_hover_contents_language_string() {
2488 let marked_string = lsp_types::MarkedString::LanguageString(lsp_types::LanguageString {
2489 language: "rust".to_string(),
2490 value: "fn main() {}".to_string(),
2491 });
2492 let contents = lsp_types::HoverContents::Scalar(marked_string);
2493 let result = extract_hover_contents(contents);
2494 assert_eq!(result, "```rust\nfn main() {}\n```");
2495 }
2496
2497 #[test]
2498 fn test_extract_hover_contents_markup() {
2499 let markup = lsp_types::MarkupContent {
2500 kind: lsp_types::MarkupKind::Markdown,
2501 value: "# Documentation".to_string(),
2502 };
2503 let contents = lsp_types::HoverContents::Markup(markup);
2504 let result = extract_hover_contents(contents);
2505 assert_eq!(result, "# Documentation");
2506 }
2507
2508 #[tokio::test]
2509 async fn test_handle_workspace_symbol_no_server() {
2510 let translator = Translator::new();
2511 let result = translator
2512 .handle_workspace_symbol("test".to_string(), None, 100)
2513 .await;
2514 assert!(matches!(result, Err(Error::NoServerConfigured)));
2515 }
2516
2517 #[tokio::test]
2518 async fn test_handle_code_actions_invalid_kind() {
2519 let translator = Translator::new();
2520 let result = translator
2521 .handle_code_actions(
2522 "/tmp/test.rs".to_string(),
2523 1,
2524 1,
2525 1,
2526 10,
2527 Some("invalid_kind".to_string()),
2528 )
2529 .await;
2530 assert!(matches!(result, Err(Error::InvalidToolParams(_))));
2531 }
2532
2533 #[tokio::test]
2534 async fn test_handle_code_actions_valid_kind_quickfix() {
2535 use tempfile::TempDir;
2536
2537 let translator = Translator::new();
2538 let temp_dir = TempDir::new().unwrap();
2539 let test_file = temp_dir.path().join("test.rs");
2540 fs::write(&test_file, "fn main() {}").unwrap();
2541
2542 let result = translator
2543 .handle_code_actions(
2544 test_file.to_str().unwrap().to_string(),
2545 1,
2546 1,
2547 1,
2548 10,
2549 Some("quickfix".to_string()),
2550 )
2551 .await;
2552 assert!(result.is_err());
2554 assert!(!matches!(result, Err(Error::InvalidToolParams(_))));
2555 }
2556
2557 #[tokio::test]
2558 async fn test_handle_code_actions_valid_kind_refactor() {
2559 use tempfile::TempDir;
2560
2561 let translator = Translator::new();
2562 let temp_dir = TempDir::new().unwrap();
2563 let test_file = temp_dir.path().join("test.rs");
2564 fs::write(&test_file, "fn main() {}").unwrap();
2565
2566 let result = translator
2567 .handle_code_actions(
2568 test_file.to_str().unwrap().to_string(),
2569 1,
2570 1,
2571 1,
2572 10,
2573 Some("refactor".to_string()),
2574 )
2575 .await;
2576 assert!(result.is_err());
2577 assert!(!matches!(result, Err(Error::InvalidToolParams(_))));
2578 }
2579
2580 #[tokio::test]
2581 async fn test_handle_code_actions_valid_kind_refactor_extract() {
2582 use tempfile::TempDir;
2583
2584 let translator = Translator::new();
2585 let temp_dir = TempDir::new().unwrap();
2586 let test_file = temp_dir.path().join("test.rs");
2587 fs::write(&test_file, "fn main() {}").unwrap();
2588
2589 let result = translator
2590 .handle_code_actions(
2591 test_file.to_str().unwrap().to_string(),
2592 1,
2593 1,
2594 1,
2595 10,
2596 Some("refactor.extract".to_string()),
2597 )
2598 .await;
2599 assert!(result.is_err());
2600 assert!(!matches!(result, Err(Error::InvalidToolParams(_))));
2601 }
2602
2603 #[tokio::test]
2604 async fn test_handle_code_actions_valid_kind_source() {
2605 use tempfile::TempDir;
2606
2607 let translator = Translator::new();
2608 let temp_dir = TempDir::new().unwrap();
2609 let test_file = temp_dir.path().join("test.rs");
2610 fs::write(&test_file, "fn main() {}").unwrap();
2611
2612 let result = translator
2613 .handle_code_actions(
2614 test_file.to_str().unwrap().to_string(),
2615 1,
2616 1,
2617 1,
2618 10,
2619 Some("source.organizeImports".to_string()),
2620 )
2621 .await;
2622 assert!(result.is_err());
2623 assert!(!matches!(result, Err(Error::InvalidToolParams(_))));
2624 }
2625
2626 #[tokio::test]
2627 async fn test_handle_code_actions_invalid_range_zero() {
2628 let translator = Translator::new();
2629 let result = translator
2630 .handle_code_actions("/tmp/test.rs".to_string(), 0, 1, 1, 10, None)
2631 .await;
2632 assert!(matches!(result, Err(Error::InvalidToolParams(_))));
2633 }
2634
2635 #[tokio::test]
2636 async fn test_handle_code_actions_invalid_range_order() {
2637 let translator = Translator::new();
2638 let result = translator
2639 .handle_code_actions("/tmp/test.rs".to_string(), 10, 5, 5, 1, None)
2640 .await;
2641 assert!(matches!(result, Err(Error::InvalidToolParams(_))));
2642 }
2643
2644 #[tokio::test]
2645 async fn test_handle_code_actions_empty_range() {
2646 use tempfile::TempDir;
2647
2648 let translator = Translator::new();
2649 let temp_dir = TempDir::new().unwrap();
2650 let test_file = temp_dir.path().join("test.rs");
2651 fs::write(&test_file, "fn main() {}").unwrap();
2652
2653 let result = translator
2655 .handle_code_actions(test_file.to_str().unwrap().to_string(), 1, 5, 1, 5, None)
2656 .await;
2657 assert!(result.is_err());
2659 assert!(!matches!(result, Err(Error::InvalidToolParams(_))));
2660 }
2661
2662 #[test]
2663 fn test_convert_code_action_minimal() {
2664 let lsp_action = lsp_types::CodeAction {
2665 title: "Fix issue".to_string(),
2666 kind: None,
2667 diagnostics: None,
2668 edit: None,
2669 command: None,
2670 is_preferred: None,
2671 disabled: None,
2672 data: None,
2673 };
2674
2675 let result = convert_code_action(lsp_action);
2676 assert_eq!(result.title, "Fix issue");
2677 assert!(result.kind.is_none());
2678 assert!(result.diagnostics.is_empty());
2679 assert!(result.edit.is_none());
2680 assert!(result.command.is_none());
2681 assert!(!result.is_preferred);
2682 }
2683
2684 #[test]
2685 #[allow(clippy::too_many_lines)]
2686 fn test_convert_code_action_with_diagnostics_all_severities() {
2687 let lsp_diagnostics = vec![
2688 lsp_types::Diagnostic {
2689 range: lsp_types::Range {
2690 start: lsp_types::Position {
2691 line: 0,
2692 character: 0,
2693 },
2694 end: lsp_types::Position {
2695 line: 0,
2696 character: 5,
2697 },
2698 },
2699 severity: Some(lsp_types::DiagnosticSeverity::ERROR),
2700 message: "Error message".to_string(),
2701 code: Some(lsp_types::NumberOrString::Number(1)),
2702 source: None,
2703 code_description: None,
2704 related_information: None,
2705 tags: None,
2706 data: None,
2707 },
2708 lsp_types::Diagnostic {
2709 range: lsp_types::Range {
2710 start: lsp_types::Position {
2711 line: 1,
2712 character: 0,
2713 },
2714 end: lsp_types::Position {
2715 line: 1,
2716 character: 5,
2717 },
2718 },
2719 severity: Some(lsp_types::DiagnosticSeverity::WARNING),
2720 message: "Warning message".to_string(),
2721 code: Some(lsp_types::NumberOrString::String("W001".to_string())),
2722 source: None,
2723 code_description: None,
2724 related_information: None,
2725 tags: None,
2726 data: None,
2727 },
2728 lsp_types::Diagnostic {
2729 range: lsp_types::Range {
2730 start: lsp_types::Position {
2731 line: 2,
2732 character: 0,
2733 },
2734 end: lsp_types::Position {
2735 line: 2,
2736 character: 5,
2737 },
2738 },
2739 severity: Some(lsp_types::DiagnosticSeverity::INFORMATION),
2740 message: "Info message".to_string(),
2741 code: None,
2742 source: None,
2743 code_description: None,
2744 related_information: None,
2745 tags: None,
2746 data: None,
2747 },
2748 lsp_types::Diagnostic {
2749 range: lsp_types::Range {
2750 start: lsp_types::Position {
2751 line: 3,
2752 character: 0,
2753 },
2754 end: lsp_types::Position {
2755 line: 3,
2756 character: 5,
2757 },
2758 },
2759 severity: Some(lsp_types::DiagnosticSeverity::HINT),
2760 message: "Hint message".to_string(),
2761 code: None,
2762 source: None,
2763 code_description: None,
2764 related_information: None,
2765 tags: None,
2766 data: None,
2767 },
2768 ];
2769
2770 let lsp_action = lsp_types::CodeAction {
2771 title: "Fix all issues".to_string(),
2772 kind: Some(lsp_types::CodeActionKind::QUICKFIX),
2773 diagnostics: Some(lsp_diagnostics),
2774 edit: None,
2775 command: None,
2776 is_preferred: None,
2777 disabled: None,
2778 data: None,
2779 };
2780
2781 let result = convert_code_action(lsp_action);
2782 assert_eq!(result.diagnostics.len(), 4);
2783 assert!(matches!(
2784 result.diagnostics[0].severity,
2785 DiagnosticSeverity::Error
2786 ));
2787 assert!(matches!(
2788 result.diagnostics[1].severity,
2789 DiagnosticSeverity::Warning
2790 ));
2791 assert!(matches!(
2792 result.diagnostics[2].severity,
2793 DiagnosticSeverity::Information
2794 ));
2795 assert!(matches!(
2796 result.diagnostics[3].severity,
2797 DiagnosticSeverity::Hint
2798 ));
2799 assert_eq!(result.diagnostics[0].code, Some("1".to_string()));
2800 assert_eq!(result.diagnostics[1].code, Some("W001".to_string()));
2801 }
2802
2803 #[test]
2804 #[allow(clippy::mutable_key_type)]
2805 fn test_convert_code_action_with_workspace_edit() {
2806 use std::collections::HashMap;
2807 use std::str::FromStr;
2808
2809 let uri = lsp_types::Uri::from_str("file:///test.rs").unwrap();
2810 let mut changes_map = HashMap::new();
2811 changes_map.insert(
2812 uri,
2813 vec![lsp_types::TextEdit {
2814 range: lsp_types::Range {
2815 start: lsp_types::Position {
2816 line: 0,
2817 character: 0,
2818 },
2819 end: lsp_types::Position {
2820 line: 0,
2821 character: 5,
2822 },
2823 },
2824 new_text: "fixed".to_string(),
2825 }],
2826 );
2827
2828 let lsp_action = lsp_types::CodeAction {
2829 title: "Apply fix".to_string(),
2830 kind: Some(lsp_types::CodeActionKind::QUICKFIX),
2831 diagnostics: None,
2832 edit: Some(lsp_types::WorkspaceEdit {
2833 changes: Some(changes_map),
2834 document_changes: None,
2835 change_annotations: None,
2836 }),
2837 command: None,
2838 is_preferred: Some(true),
2839 disabled: None,
2840 data: None,
2841 };
2842
2843 let result = convert_code_action(lsp_action);
2844 assert!(result.edit.is_some());
2845 let edit = result.edit.unwrap();
2846 assert_eq!(edit.changes.len(), 1);
2847 assert_eq!(edit.changes[0].uri, "file:///test.rs");
2848 assert_eq!(edit.changes[0].edits.len(), 1);
2849 assert_eq!(edit.changes[0].edits[0].new_text, "fixed");
2850 assert!(result.is_preferred);
2851 }
2852
2853 #[test]
2854 fn test_convert_code_action_with_command() {
2855 let lsp_action = lsp_types::CodeAction {
2856 title: "Run command".to_string(),
2857 kind: Some(lsp_types::CodeActionKind::REFACTOR),
2858 diagnostics: None,
2859 edit: None,
2860 command: Some(lsp_types::Command {
2861 title: "Execute refactor".to_string(),
2862 command: "refactor.extract".to_string(),
2863 arguments: Some(vec![serde_json::json!("arg1"), serde_json::json!(42)]),
2864 }),
2865 is_preferred: None,
2866 disabled: None,
2867 data: None,
2868 };
2869
2870 let result = convert_code_action(lsp_action);
2871 assert!(result.command.is_some());
2872 let cmd = result.command.unwrap();
2873 assert_eq!(cmd.title, "Execute refactor");
2874 assert_eq!(cmd.command, "refactor.extract");
2875 assert_eq!(cmd.arguments.len(), 2);
2876 }
2877
2878 #[tokio::test]
2879 async fn test_handle_call_hierarchy_prepare_invalid_position_zero() {
2880 let translator = Translator::new();
2881 let result = translator
2882 .handle_call_hierarchy_prepare("/tmp/test.rs".to_string(), 0, 1)
2883 .await;
2884 assert!(matches!(result, Err(Error::InvalidToolParams(_))));
2885
2886 let result = translator
2887 .handle_call_hierarchy_prepare("/tmp/test.rs".to_string(), 1, 0)
2888 .await;
2889 assert!(matches!(result, Err(Error::InvalidToolParams(_))));
2890 }
2891
2892 #[tokio::test]
2893 async fn test_handle_call_hierarchy_prepare_invalid_position_too_large() {
2894 let translator = Translator::new();
2895 let result = translator
2896 .handle_call_hierarchy_prepare("/tmp/test.rs".to_string(), 1_000_001, 1)
2897 .await;
2898 assert!(matches!(result, Err(Error::InvalidToolParams(_))));
2899
2900 let result = translator
2901 .handle_call_hierarchy_prepare("/tmp/test.rs".to_string(), 1, 1_000_001)
2902 .await;
2903 assert!(matches!(result, Err(Error::InvalidToolParams(_))));
2904 }
2905
2906 #[tokio::test]
2907 async fn test_handle_incoming_calls_invalid_json() {
2908 let translator = Translator::new();
2909 let invalid_item = serde_json::json!({"invalid": "structure"});
2910 let result = translator.handle_incoming_calls(invalid_item).await;
2911 assert!(matches!(result, Err(Error::InvalidToolParams(_))));
2912 }
2913
2914 #[tokio::test]
2915 async fn test_handle_outgoing_calls_invalid_json() {
2916 let translator = Translator::new();
2917 let invalid_item = serde_json::json!({"invalid": "structure"});
2918 let result = translator.handle_outgoing_calls(invalid_item).await;
2919 assert!(matches!(result, Err(Error::InvalidToolParams(_))));
2920 }
2921
2922 #[tokio::test]
2923 async fn test_parse_file_uri_invalid_scheme() {
2924 let translator = Translator::new();
2925 let uri: lsp_types::Uri = "http://example.com/file.rs".parse().unwrap();
2926 let result = translator.parse_file_uri(&uri);
2927 assert!(matches!(result, Err(Error::InvalidToolParams(_))));
2928 }
2929
2930 #[tokio::test]
2931 async fn test_parse_file_uri_valid_scheme() {
2932 let translator = Translator::new();
2933 let temp_dir = TempDir::new().unwrap();
2934 let test_file = temp_dir.path().join("test.rs");
2935 fs::write(&test_file, "fn main() {}").unwrap();
2936
2937 let file_url = Url::from_file_path(&test_file).unwrap();
2939 let uri: lsp_types::Uri = file_url.as_str().parse().unwrap();
2940 let result = translator.parse_file_uri(&uri);
2941 assert!(result.is_ok());
2942 }
2943
2944 #[test]
2945 fn test_handle_cached_diagnostics_empty() {
2946 let cache = NotificationCache::new();
2947 let temp_dir = TempDir::new().unwrap();
2948 let test_file = temp_dir.path().join("test.rs");
2949 fs::write(&test_file, "fn main() {}").unwrap();
2950
2951 let cache_key =
2952 Translator::cached_diagnostics_uri(&[], test_file.to_str().unwrap()).unwrap();
2953 let diag_info = cache.get_diagnostics(&cache_key).cloned();
2954 let diags = Translator::diagnostics_from_cache_entry(diag_info.as_ref());
2955 assert_eq!(diags.diagnostics.len(), 0);
2956 }
2957
2958 #[test]
2959 fn test_handle_server_logs_with_filter() {
2960 use crate::bridge::notifications::LogLevel;
2961
2962 let mut cache = NotificationCache::new();
2963
2964 cache.store_log(LogLevel::Error, "error msg".to_string());
2966 cache.store_log(LogLevel::Warning, "warning msg".to_string());
2967 cache.store_log(LogLevel::Info, "info msg".to_string());
2968 cache.store_log(LogLevel::Debug, "debug msg".to_string());
2969
2970 let result = Translator::handle_server_logs(&cache, 10, Some("error".to_string()));
2972 assert!(result.is_ok());
2973 let logs = result.unwrap();
2974 assert_eq!(logs.logs.len(), 1);
2975 assert_eq!(logs.logs[0].message, "error msg");
2976
2977 let result = Translator::handle_server_logs(&cache, 10, Some("warning".to_string()));
2979 assert!(result.is_ok());
2980 let logs = result.unwrap();
2981 assert_eq!(logs.logs.len(), 2);
2982
2983 let result = Translator::handle_server_logs(&cache, 10, Some("info".to_string()));
2985 assert!(result.is_ok());
2986 let logs = result.unwrap();
2987 assert_eq!(logs.logs.len(), 3);
2988
2989 let result = Translator::handle_server_logs(&cache, 10, Some("debug".to_string()));
2991 assert!(result.is_ok());
2992 let logs = result.unwrap();
2993 assert_eq!(logs.logs.len(), 4);
2994
2995 let result = Translator::handle_server_logs(&cache, 10, Some("invalid".to_string()));
2997 assert!(matches!(result, Err(Error::InvalidToolParams(_))));
2998 }
2999
3000 #[test]
3001 fn test_handle_server_messages_limit() {
3002 use crate::bridge::notifications::MessageType;
3003
3004 let mut cache = NotificationCache::new();
3005
3006 for i in 0..10 {
3008 cache.store_message(MessageType::Info, format!("message {i}"));
3009 }
3010
3011 let result = Translator::handle_server_messages(&cache, 5);
3013 assert!(result.is_ok());
3014 let messages = result.unwrap();
3015 assert_eq!(messages.messages.len(), 5);
3016 assert_eq!(messages.messages[0].message, "message 0");
3017 assert_eq!(messages.messages[4].message, "message 4");
3018
3019 let result = Translator::handle_server_messages(&cache, 100);
3021 assert!(result.is_ok());
3022 let messages = result.unwrap();
3023 assert_eq!(messages.messages.len(), 10);
3024 }
3025
3026 #[test]
3027 fn test_handle_cached_diagnostics_with_data() {
3028 let mut cache = NotificationCache::new();
3029 let temp_dir = TempDir::new().unwrap();
3030 let test_file = temp_dir.path().join("test.rs");
3031 fs::write(&test_file, "fn main() {}").unwrap();
3032
3033 let canonical_path = test_file.canonicalize().unwrap();
3034 let uri: lsp_types::Uri = Url::from_file_path(&canonical_path)
3035 .unwrap()
3036 .as_str()
3037 .parse()
3038 .unwrap();
3039 let diagnostic = lsp_types::Diagnostic {
3040 range: lsp_types::Range {
3041 start: lsp_types::Position {
3042 line: 0,
3043 character: 0,
3044 },
3045 end: lsp_types::Position {
3046 line: 0,
3047 character: 5,
3048 },
3049 },
3050 severity: Some(lsp_types::DiagnosticSeverity::ERROR),
3051 message: "test error".to_string(),
3052 code: Some(lsp_types::NumberOrString::String("E001".to_string())),
3053 source: None,
3054 code_description: None,
3055 related_information: None,
3056 tags: None,
3057 data: None,
3058 };
3059
3060 cache.store_diagnostics(&uri, Some(1), vec![diagnostic]);
3061
3062 let cache_key =
3063 Translator::cached_diagnostics_uri(&[], test_file.to_str().unwrap()).unwrap();
3064 let diag_info = cache.get_diagnostics(&cache_key).cloned();
3065 let diags = Translator::diagnostics_from_cache_entry(diag_info.as_ref());
3066 assert_eq!(diags.diagnostics.len(), 1);
3067 assert_eq!(diags.diagnostics[0].message, "test error");
3068 assert_eq!(diags.diagnostics[0].code, Some("E001".to_string()));
3069 assert!(matches!(
3070 diags.diagnostics[0].severity,
3071 DiagnosticSeverity::Error
3072 ));
3073 assert_eq!(diags.diagnostics[0].range.start.line, 1);
3074 assert_eq!(diags.diagnostics[0].range.start.character, 1);
3075 }
3076
3077 #[test]
3078 #[allow(clippy::too_many_lines)]
3079 fn test_handle_cached_diagnostics_multiple_severities() {
3080 let mut cache = NotificationCache::new();
3081 let temp_dir = TempDir::new().unwrap();
3082 let test_file = temp_dir.path().join("test.rs");
3083 fs::write(&test_file, "fn main() {}").unwrap();
3084
3085 let canonical_path = test_file.canonicalize().unwrap();
3086 let uri: lsp_types::Uri = Url::from_file_path(&canonical_path)
3087 .unwrap()
3088 .as_str()
3089 .parse()
3090 .unwrap();
3091 let diagnostics = vec![
3092 lsp_types::Diagnostic {
3093 range: lsp_types::Range {
3094 start: lsp_types::Position {
3095 line: 0,
3096 character: 0,
3097 },
3098 end: lsp_types::Position {
3099 line: 0,
3100 character: 5,
3101 },
3102 },
3103 severity: Some(lsp_types::DiagnosticSeverity::ERROR),
3104 message: "error".to_string(),
3105 code: None,
3106 source: None,
3107 code_description: None,
3108 related_information: None,
3109 tags: None,
3110 data: None,
3111 },
3112 lsp_types::Diagnostic {
3113 range: lsp_types::Range {
3114 start: lsp_types::Position {
3115 line: 1,
3116 character: 0,
3117 },
3118 end: lsp_types::Position {
3119 line: 1,
3120 character: 5,
3121 },
3122 },
3123 severity: Some(lsp_types::DiagnosticSeverity::WARNING),
3124 message: "warning".to_string(),
3125 code: None,
3126 source: None,
3127 code_description: None,
3128 related_information: None,
3129 tags: None,
3130 data: None,
3131 },
3132 lsp_types::Diagnostic {
3133 range: lsp_types::Range {
3134 start: lsp_types::Position {
3135 line: 2,
3136 character: 0,
3137 },
3138 end: lsp_types::Position {
3139 line: 2,
3140 character: 5,
3141 },
3142 },
3143 severity: Some(lsp_types::DiagnosticSeverity::INFORMATION),
3144 message: "info".to_string(),
3145 code: None,
3146 source: None,
3147 code_description: None,
3148 related_information: None,
3149 tags: None,
3150 data: None,
3151 },
3152 lsp_types::Diagnostic {
3153 range: lsp_types::Range {
3154 start: lsp_types::Position {
3155 line: 3,
3156 character: 0,
3157 },
3158 end: lsp_types::Position {
3159 line: 3,
3160 character: 5,
3161 },
3162 },
3163 severity: Some(lsp_types::DiagnosticSeverity::HINT),
3164 message: "hint".to_string(),
3165 code: None,
3166 source: None,
3167 code_description: None,
3168 related_information: None,
3169 tags: None,
3170 data: None,
3171 },
3172 ];
3173
3174 cache.store_diagnostics(&uri, Some(1), diagnostics);
3175
3176 let cache_key =
3177 Translator::cached_diagnostics_uri(&[], test_file.to_str().unwrap()).unwrap();
3178 let diag_info = cache.get_diagnostics(&cache_key).cloned();
3179 let diags = Translator::diagnostics_from_cache_entry(diag_info.as_ref());
3180 assert_eq!(diags.diagnostics.len(), 4);
3181 assert!(matches!(
3182 diags.diagnostics[0].severity,
3183 DiagnosticSeverity::Error
3184 ));
3185 assert!(matches!(
3186 diags.diagnostics[1].severity,
3187 DiagnosticSeverity::Warning
3188 ));
3189 assert!(matches!(
3190 diags.diagnostics[2].severity,
3191 DiagnosticSeverity::Information
3192 ));
3193 assert!(matches!(
3194 diags.diagnostics[3].severity,
3195 DiagnosticSeverity::Hint
3196 ));
3197 }
3198
3199 #[test]
3200 fn test_handle_cached_diagnostics_with_numeric_code() {
3201 let mut cache = NotificationCache::new();
3202 let temp_dir = TempDir::new().unwrap();
3203 let test_file = temp_dir.path().join("test.rs");
3204 fs::write(&test_file, "fn main() {}").unwrap();
3205
3206 let canonical_path = test_file.canonicalize().unwrap();
3207 let uri: lsp_types::Uri = Url::from_file_path(&canonical_path)
3208 .unwrap()
3209 .as_str()
3210 .parse()
3211 .unwrap();
3212 let diagnostic = lsp_types::Diagnostic {
3213 range: lsp_types::Range {
3214 start: lsp_types::Position {
3215 line: 0,
3216 character: 0,
3217 },
3218 end: lsp_types::Position {
3219 line: 0,
3220 character: 5,
3221 },
3222 },
3223 severity: Some(lsp_types::DiagnosticSeverity::ERROR),
3224 message: "test error".to_string(),
3225 code: Some(lsp_types::NumberOrString::Number(42)),
3226 source: None,
3227 code_description: None,
3228 related_information: None,
3229 tags: None,
3230 data: None,
3231 };
3232
3233 cache.store_diagnostics(&uri, Some(1), vec![diagnostic]);
3234
3235 let cache_key =
3236 Translator::cached_diagnostics_uri(&[], test_file.to_str().unwrap()).unwrap();
3237 let diag_info = cache.get_diagnostics(&cache_key).cloned();
3238 let diags = Translator::diagnostics_from_cache_entry(diag_info.as_ref());
3239 assert_eq!(diags.diagnostics.len(), 1);
3240 assert_eq!(diags.diagnostics[0].code, Some("42".to_string()));
3241 }
3242
3243 #[test]
3244 fn test_handle_cached_diagnostics_invalid_path() {
3245 let result = Translator::cached_diagnostics_uri(&[], "/nonexistent/path/file.rs");
3246 assert!(matches!(result, Err(Error::FileIo { .. })));
3247 }
3248
3249 fn lsp_diag(
3251 line: u32,
3252 end_character: u32,
3253 severity: lsp_types::DiagnosticSeverity,
3254 message: &str,
3255 code: Option<&str>,
3256 ) -> lsp_types::Diagnostic {
3257 lsp_types::Diagnostic {
3258 range: lsp_types::Range {
3259 start: lsp_types::Position { line, character: 0 },
3260 end: lsp_types::Position {
3261 line,
3262 character: end_character,
3263 },
3264 },
3265 severity: Some(severity),
3266 message: message.to_string(),
3267 code: code.map(|c| lsp_types::NumberOrString::String(c.to_string())),
3268 source: None,
3269 code_description: None,
3270 related_information: None,
3271 tags: None,
3272 data: None,
3273 }
3274 }
3275
3276 fn diag_info(diagnostics: Vec<lsp_types::Diagnostic>) -> DiagnosticInfo {
3277 DiagnosticInfo {
3278 uri: "file:///test.rs".parse().unwrap(),
3279 version: Some(1),
3280 diagnostics,
3281 }
3282 }
3283
3284 #[test]
3285 fn test_merge_diagnostics_cache_only_appends_to_empty_pull() {
3286 let pull = DiagnosticsResult {
3287 diagnostics: vec![],
3288 };
3289 let cache = diag_info(vec![lsp_diag(
3290 0,
3291 10,
3292 lsp_types::DiagnosticSeverity::WARNING,
3293 "unused import: `std::fmt`",
3294 None,
3295 )]);
3296
3297 let merged = Translator::merge_diagnostics(pull, Some(&cache));
3298
3299 assert_eq!(merged.diagnostics.len(), 1);
3300 assert_eq!(merged.diagnostics[0].message, "unused import: `std::fmt`");
3301 assert!(matches!(
3302 merged.diagnostics[0].severity,
3303 DiagnosticSeverity::Warning
3304 ));
3305 }
3306
3307 #[test]
3308 fn test_merge_diagnostics_exact_duplicate_not_repeated() {
3309 let pull_diag = Diagnostic {
3312 range: Range {
3313 start: Position2D {
3314 line: 1,
3315 character: 1,
3316 },
3317 end: Position2D {
3318 line: 1,
3319 character: 11,
3320 },
3321 },
3322 severity: DiagnosticSeverity::Error,
3323 message: "mismatched types".to_string(),
3324 code: Some("E0308".to_string()),
3325 };
3326 let pull = DiagnosticsResult {
3327 diagnostics: vec![pull_diag.clone()],
3328 };
3329 let cache = diag_info(vec![lsp_diag(
3330 0,
3331 10,
3332 lsp_types::DiagnosticSeverity::ERROR,
3333 "mismatched types",
3334 Some("E0308"),
3335 )]);
3336
3337 let merged = Translator::merge_diagnostics(pull, Some(&cache));
3338
3339 assert_eq!(merged.diagnostics.len(), 1);
3340 assert_eq!(merged.diagnostics[0], pull_diag);
3341 }
3342
3343 #[test]
3344 fn test_merge_diagnostics_no_cache_entry_returns_pull_unchanged() {
3345 let pull_diag = Diagnostic {
3346 range: Range {
3347 start: Position2D {
3348 line: 1,
3349 character: 1,
3350 },
3351 end: Position2D {
3352 line: 1,
3353 character: 5,
3354 },
3355 },
3356 severity: DiagnosticSeverity::Error,
3357 message: "syntax error".to_string(),
3358 code: None,
3359 };
3360 let pull = DiagnosticsResult {
3361 diagnostics: vec![pull_diag.clone()],
3362 };
3363
3364 let merged = Translator::merge_diagnostics(pull, None);
3365
3366 assert_eq!(merged.diagnostics, vec![pull_diag]);
3367 }
3368
3369 #[test]
3370 fn test_merge_diagnostics_multiple_distinct_cache_entries_all_appear() {
3371 let pull = DiagnosticsResult {
3372 diagnostics: vec![],
3373 };
3374 let cache = diag_info(vec![
3375 lsp_diag(
3376 0,
3377 10,
3378 lsp_types::DiagnosticSeverity::WARNING,
3379 "unused import: `std::fmt`",
3380 None,
3381 ),
3382 lsp_diag(
3383 5,
3384 8,
3385 lsp_types::DiagnosticSeverity::WARNING,
3386 "function `helper` is never used",
3387 None,
3388 ),
3389 ]);
3390
3391 let merged = Translator::merge_diagnostics(pull, Some(&cache));
3392
3393 assert_eq!(merged.diagnostics.len(), 2);
3394 assert!(
3395 merged
3396 .diagnostics
3397 .iter()
3398 .any(|d| d.message == "unused import: `std::fmt`")
3399 );
3400 assert!(
3401 merged
3402 .diagnostics
3403 .iter()
3404 .any(|d| d.message == "function `helper` is never used")
3405 );
3406 }
3407
3408 #[test]
3409 fn test_merge_diagnostics_same_range_different_message_not_deduped() {
3410 let pull_diag = Diagnostic {
3411 range: Range {
3412 start: Position2D {
3413 line: 1,
3414 character: 1,
3415 },
3416 end: Position2D {
3417 line: 1,
3418 character: 11,
3419 },
3420 },
3421 severity: DiagnosticSeverity::Error,
3422 message: "mismatched types".to_string(),
3423 code: None,
3424 };
3425 let pull = DiagnosticsResult {
3426 diagnostics: vec![pull_diag],
3427 };
3428 let cache = diag_info(vec![lsp_diag(
3431 0,
3432 10,
3433 lsp_types::DiagnosticSeverity::ERROR,
3434 "expected `i32`, found `&str`",
3435 None,
3436 )]);
3437
3438 let merged = Translator::merge_diagnostics(pull, Some(&cache));
3439
3440 assert_eq!(merged.diagnostics.len(), 2);
3441 }
3442
3443 #[test]
3451 fn test_merge_diagnostics_same_code_different_range_and_message_deduped() {
3452 let pull_diag = Diagnostic {
3453 range: Range {
3454 start: Position2D {
3455 line: 96,
3456 character: 7,
3457 },
3458 end: Position2D {
3459 line: 96,
3460 character: 12,
3461 },
3462 },
3463 severity: DiagnosticSeverity::Error,
3464 message: "not all trait items implemented, missing: `fn hello`".to_string(),
3465 code: Some("E0046".to_string()),
3466 };
3467 let pull = DiagnosticsResult {
3468 diagnostics: vec![pull_diag.clone()],
3469 };
3470 let cache = diag_info(vec![lsp_diag(
3474 94,
3475 31,
3476 lsp_types::DiagnosticSeverity::ERROR,
3477 "not all trait items implemented, missing: `hello`\nmissing `hello` in implementation",
3478 Some("E0046"),
3479 )]);
3480
3481 let merged = Translator::merge_diagnostics(pull, Some(&cache));
3482
3483 assert_eq!(merged.diagnostics.len(), 1);
3484 assert_eq!(merged.diagnostics[0], pull_diag);
3485 }
3486
3487 #[test]
3502 fn test_merge_diagnostics_same_code_distinct_diagnostics_at_different_locations_both_kept() {
3503 let pull_diag = Diagnostic {
3504 range: Range {
3505 start: Position2D {
3506 line: 5,
3507 character: 9,
3508 },
3509 end: Position2D {
3510 line: 5,
3511 character: 20,
3512 },
3513 },
3514 severity: DiagnosticSeverity::Error,
3515 message: "mismatched types: expected `i32`, found `&str`".to_string(),
3516 code: Some("E0308".to_string()),
3517 };
3518 let pull = DiagnosticsResult {
3519 diagnostics: vec![pull_diag.clone()],
3520 };
3521 let cache = diag_info(vec![lsp_diag(
3525 49,
3526 22,
3527 lsp_types::DiagnosticSeverity::ERROR,
3528 "mismatched types: expected `String`, found `Vec<u8>`",
3529 Some("E0308"),
3530 )]);
3531
3532 let merged = Translator::merge_diagnostics(pull, Some(&cache));
3533
3534 assert_eq!(merged.diagnostics.len(), 2);
3535 assert_eq!(merged.diagnostics[0], pull_diag);
3536 assert_eq!(
3537 merged.diagnostics[1].message,
3538 "mismatched types: expected `String`, found `Vec<u8>`"
3539 );
3540 }
3541
3542 #[test]
3543 fn test_handle_server_logs_no_filter() {
3544 use crate::bridge::notifications::LogLevel;
3545
3546 let mut cache = NotificationCache::new();
3547
3548 cache.store_log(LogLevel::Error, "error msg".to_string());
3549 cache.store_log(LogLevel::Warning, "warning msg".to_string());
3550 cache.store_log(LogLevel::Info, "info msg".to_string());
3551 cache.store_log(LogLevel::Debug, "debug msg".to_string());
3552
3553 let result = Translator::handle_server_logs(&cache, 10, None);
3554 assert!(result.is_ok());
3555 let logs = result.unwrap();
3556 assert_eq!(logs.logs.len(), 4);
3557 }
3558
3559 #[test]
3560 fn test_handle_server_logs_error_filter_strict() {
3561 use crate::bridge::notifications::LogLevel;
3562
3563 let mut cache = NotificationCache::new();
3564
3565 cache.store_log(LogLevel::Error, "error msg".to_string());
3566 cache.store_log(LogLevel::Warning, "warning msg".to_string());
3567 cache.store_log(LogLevel::Info, "info msg".to_string());
3568
3569 let result = Translator::handle_server_logs(&cache, 10, Some("error".to_string()));
3570 assert!(result.is_ok());
3571 let logs = result.unwrap();
3572 assert_eq!(logs.logs.len(), 1);
3573 assert_eq!(logs.logs[0].message, "error msg");
3574 }
3575
3576 #[test]
3577 fn test_handle_server_logs_warning_filter_includes_errors() {
3578 use crate::bridge::notifications::LogLevel;
3579
3580 let mut cache = NotificationCache::new();
3581
3582 cache.store_log(LogLevel::Error, "error msg".to_string());
3583 cache.store_log(LogLevel::Warning, "warning msg".to_string());
3584 cache.store_log(LogLevel::Info, "info msg".to_string());
3585
3586 let result = Translator::handle_server_logs(&cache, 10, Some("warning".to_string()));
3587 assert!(result.is_ok());
3588 let logs = result.unwrap();
3589 assert_eq!(logs.logs.len(), 2);
3590 }
3591
3592 #[test]
3593 fn test_handle_server_logs_info_filter_excludes_debug() {
3594 use crate::bridge::notifications::LogLevel;
3595
3596 let mut cache = NotificationCache::new();
3597
3598 cache.store_log(LogLevel::Error, "error msg".to_string());
3599 cache.store_log(LogLevel::Info, "info msg".to_string());
3600 cache.store_log(LogLevel::Debug, "debug msg".to_string());
3601
3602 let result = Translator::handle_server_logs(&cache, 10, Some("info".to_string()));
3603 assert!(result.is_ok());
3604 let logs = result.unwrap();
3605 assert_eq!(logs.logs.len(), 2);
3606 }
3607
3608 #[test]
3609 fn test_handle_server_logs_debug_filter_includes_all() {
3610 use crate::bridge::notifications::LogLevel;
3611
3612 let mut cache = NotificationCache::new();
3613
3614 cache.store_log(LogLevel::Error, "error msg".to_string());
3615 cache.store_log(LogLevel::Warning, "warning msg".to_string());
3616 cache.store_log(LogLevel::Info, "info msg".to_string());
3617 cache.store_log(LogLevel::Debug, "debug msg".to_string());
3618
3619 let result = Translator::handle_server_logs(&cache, 10, Some("debug".to_string()));
3620 assert!(result.is_ok());
3621 let logs = result.unwrap();
3622 assert_eq!(logs.logs.len(), 4);
3623 }
3624
3625 #[test]
3626 fn test_handle_server_logs_limit_applies_after_filter() {
3627 use crate::bridge::notifications::LogLevel;
3628
3629 let mut cache = NotificationCache::new();
3630
3631 for i in 0..10 {
3632 cache.store_log(LogLevel::Error, format!("error {i}"));
3633 }
3634
3635 let result = Translator::handle_server_logs(&cache, 5, Some("error".to_string()));
3636 assert!(result.is_ok());
3637 let logs = result.unwrap();
3638 assert_eq!(logs.logs.len(), 5);
3639 assert_eq!(logs.logs[0].message, "error 0");
3640 assert_eq!(logs.logs[4].message, "error 4");
3641 }
3642
3643 #[test]
3644 fn test_handle_server_logs_case_insensitive_level() {
3645 use crate::bridge::notifications::LogLevel;
3646
3647 let mut cache = NotificationCache::new();
3648
3649 cache.store_log(LogLevel::Error, "error msg".to_string());
3650
3651 let result = Translator::handle_server_logs(&cache, 10, Some("ERROR".to_string()));
3652 assert!(result.is_ok());
3653
3654 let result = Translator::handle_server_logs(&cache, 10, Some("Error".to_string()));
3655 assert!(result.is_ok());
3656
3657 let result = Translator::handle_server_logs(&cache, 10, Some("eRrOr".to_string()));
3658 assert!(result.is_ok());
3659 }
3660
3661 #[test]
3662 fn test_handle_server_messages_empty() {
3663 let cache = NotificationCache::new();
3664
3665 let result = Translator::handle_server_messages(&cache, 10);
3666 assert!(result.is_ok());
3667 let messages = result.unwrap();
3668 assert_eq!(messages.messages.len(), 0);
3669 }
3670
3671 #[test]
3672 fn test_handle_server_messages_with_different_types() {
3673 use crate::bridge::notifications::MessageType;
3674
3675 let mut cache = NotificationCache::new();
3676
3677 cache.store_message(MessageType::Error, "error".to_string());
3678 cache.store_message(MessageType::Warning, "warning".to_string());
3679 cache.store_message(MessageType::Info, "info".to_string());
3680 cache.store_message(MessageType::Log, "log".to_string());
3681
3682 let result = Translator::handle_server_messages(&cache, 10);
3683 assert!(result.is_ok());
3684 let messages = result.unwrap();
3685 assert_eq!(messages.messages.len(), 4);
3686 assert_eq!(messages.messages[0].message, "error");
3687 assert_eq!(messages.messages[1].message, "warning");
3688 assert_eq!(messages.messages[2].message, "info");
3689 assert_eq!(messages.messages[3].message, "log");
3690 }
3691
3692 #[test]
3693 fn test_handle_server_messages_zero_limit() {
3694 use crate::bridge::notifications::MessageType;
3695
3696 let mut cache = NotificationCache::new();
3697
3698 cache.store_message(MessageType::Info, "test".to_string());
3699
3700 let result = Translator::handle_server_messages(&cache, 0);
3701 assert!(result.is_ok());
3702 let messages = result.unwrap();
3703 assert_eq!(messages.messages.len(), 0);
3704 }
3705
3706 #[test]
3707 fn test_handle_cached_diagnostics_path_outside_workspace() {
3708 let temp_dir1 = TempDir::new().unwrap();
3709 let temp_dir2 = TempDir::new().unwrap();
3710
3711 let workspace_roots = vec![temp_dir1.path().to_path_buf()];
3712
3713 let test_file = temp_dir2.path().join("test.rs");
3714 fs::write(&test_file, "fn main() {}").unwrap();
3715
3716 let result =
3717 Translator::cached_diagnostics_uri(&workspace_roots, test_file.to_str().unwrap());
3718 assert!(matches!(result, Err(Error::PathOutsideWorkspace(_))));
3719 }
3720
3721 #[test]
3722 fn test_translator_with_custom_extensions() {
3723 let mut extension_map = HashMap::new();
3724 extension_map.insert("nu".to_string(), "nushell".to_string());
3725 extension_map.insert("customext".to_string(), "customlang".to_string());
3726
3727 let translator = Translator::new().with_extensions(extension_map.clone());
3728
3729 assert_eq!(translator.extension_map.len(), 2);
3730 assert_eq!(
3731 translator.extension_map.get("nu"),
3732 Some(&"nushell".to_string())
3733 );
3734 assert_eq!(
3735 translator.extension_map.get("customext"),
3736 Some(&"customlang".to_string())
3737 );
3738 }
3739
3740 #[test]
3741 fn test_get_client_for_file_uses_custom_extension() {
3742 let temp_dir = TempDir::new().unwrap();
3743 let test_file = temp_dir.path().join("script.nu");
3744 fs::write(&test_file, "echo hello").unwrap();
3745
3746 let mut extension_map = HashMap::new();
3747 extension_map.insert("nu".to_string(), "nushell".to_string());
3748
3749 let translator = Translator::new().with_extensions(extension_map);
3750
3751 let result = translator.get_client_for_file(&test_file, ToolKind::Hover);
3752
3753 assert!(result.is_err());
3754 if let Err(Error::NoServerForLanguage(lang)) = result {
3755 assert_eq!(lang, "nushell");
3756 } else {
3757 panic!("Expected NoServerForLanguage(nushell) error");
3758 }
3759 }
3760
3761 #[test]
3762 fn test_get_client_for_file_falls_back_to_default() {
3763 let temp_dir = TempDir::new().unwrap();
3764 let test_file = temp_dir.path().join("unknown.xyz");
3765 fs::write(&test_file, "content").unwrap();
3766
3767 let mut extension_map = HashMap::new();
3768 extension_map.insert("rs".to_string(), "rust".to_string());
3769
3770 let translator = Translator::new().with_extensions(extension_map);
3771
3772 let result = translator.get_client_for_file(&test_file, ToolKind::Hover);
3773
3774 assert!(result.is_err());
3775 if let Err(Error::NoServerForLanguage(lang)) = result {
3776 assert_eq!(lang, "plaintext");
3777 } else {
3778 panic!("Expected NoServerForLanguage(plaintext) error");
3779 }
3780 }
3781
3782 #[test]
3783 fn test_get_client_for_file_routes_tsx_to_typescript_server() {
3784 let temp_dir = TempDir::new().unwrap();
3785 let test_file = temp_dir.path().join("component.tsx");
3786 fs::write(&test_file, "export const Component = () => <div />").unwrap();
3787
3788 let mut extension_map = HashMap::new();
3789 extension_map.insert("tsx".to_string(), "typescriptreact".to_string());
3790
3791 let translator = Translator::new()
3792 .with_extensions(extension_map)
3793 .with_router(ToolRouter::catch_all([(
3794 ServerId::from("typescript"),
3795 "typescript".to_string(),
3796 )]));
3797 translator.register_client(
3798 "typescript".to_string(),
3799 LspClient::new(crate::config::LspServerConfig::typescript()),
3800 );
3801
3802 let (_id, client) = translator
3803 .get_client_for_file(&test_file, ToolKind::Hover)
3804 .unwrap();
3805 assert_eq!(client.language_id(), "typescript");
3806 }
3807
3808 #[test]
3809 fn test_get_client_for_file_prefers_exact_react_server() {
3810 let temp_dir = TempDir::new().unwrap();
3811 let test_file = temp_dir.path().join("component.tsx");
3812 fs::write(&test_file, "export const Component = () => <div />").unwrap();
3813
3814 let mut extension_map = HashMap::new();
3815 extension_map.insert("tsx".to_string(), "typescriptreact".to_string());
3816
3817 let typescript_react_config = crate::config::LspServerConfig {
3818 language_id: "typescriptreact".to_string(),
3819 command: "typescript-language-server".to_string(),
3820 args: vec!["--stdio".to_string()],
3821 env: HashMap::new(),
3822 file_patterns: vec!["**/*.tsx".to_string()],
3823 initialization_options: None,
3824 timeout_seconds: 30,
3825 heuristics: None,
3826 name: None,
3827 handles: None,
3828 };
3829
3830 let translator = Translator::new()
3831 .with_extensions(extension_map)
3832 .with_router(ToolRouter::catch_all([
3833 (ServerId::from("typescript"), "typescript".to_string()),
3834 (
3835 ServerId::from("typescriptreact"),
3836 "typescriptreact".to_string(),
3837 ),
3838 ]));
3839 translator.register_client(
3840 "typescript".to_string(),
3841 LspClient::new(crate::config::LspServerConfig::typescript()),
3842 );
3843 translator.register_client(
3844 "typescriptreact".to_string(),
3845 LspClient::new(typescript_react_config),
3846 );
3847
3848 let (_id, client) = translator
3849 .get_client_for_file(&test_file, ToolKind::Hover)
3850 .unwrap();
3851 assert_eq!(client.language_id(), "typescriptreact");
3852 }
3853
3854 #[test]
3855 fn test_get_client_for_file_routes_jsx_to_javascript_server() {
3856 let temp_dir = TempDir::new().unwrap();
3857 let test_file = temp_dir.path().join("component.jsx");
3858 fs::write(&test_file, "export const Component = () => <div />").unwrap();
3859
3860 let mut extension_map = HashMap::new();
3861 extension_map.insert("jsx".to_string(), "javascriptreact".to_string());
3862
3863 let javascript_config = crate::config::LspServerConfig {
3864 language_id: "javascript".to_string(),
3865 command: "typescript-language-server".to_string(),
3866 args: vec!["--stdio".to_string()],
3867 env: HashMap::new(),
3868 file_patterns: vec!["**/*.js".to_string(), "**/*.jsx".to_string()],
3869 initialization_options: None,
3870 timeout_seconds: 30,
3871 heuristics: None,
3872 name: None,
3873 handles: None,
3874 };
3875 let translator = Translator::new()
3876 .with_extensions(extension_map)
3877 .with_router(ToolRouter::catch_all([(
3878 ServerId::from("javascript"),
3879 "javascript".to_string(),
3880 )]));
3881 translator.register_client("javascript".to_string(), LspClient::new(javascript_config));
3882
3883 let (_id, client) = translator
3884 .get_client_for_file(&test_file, ToolKind::Hover)
3885 .unwrap();
3886 assert_eq!(client.language_id(), "javascript");
3887 }
3888
3889 #[tokio::test]
3890 async fn test_serve_initializes_translator_with_extensions() {
3891 use crate::config::{LanguageExtensionMapping, WorkspaceConfig};
3892
3893 let language_extensions = vec![
3894 LanguageExtensionMapping {
3895 extensions: vec!["nu".to_string()],
3896 language_id: "nushell".to_string(),
3897 },
3898 LanguageExtensionMapping {
3899 extensions: vec!["rs".to_string()],
3900 language_id: "rust".to_string(),
3901 },
3902 ];
3903
3904 let config = crate::config::ServerConfig {
3905 workspace: WorkspaceConfig {
3906 roots: vec![PathBuf::from("/tmp/test-workspace")],
3907 position_encodings: vec!["utf-8".to_string()],
3908 language_extensions: language_extensions.clone(),
3909 heuristics_max_depth: 10,
3910 },
3911 lsp_servers: vec![],
3912 };
3913
3914 let extension_map = config.build_effective_extension_map();
3915 assert_eq!(extension_map.get("nu"), Some(&"nushell".to_string()));
3916 assert_eq!(extension_map.get("rs"), Some(&"rust".to_string()));
3917
3918 let result = crate::serve(config).await;
3921 if let Err(ref err) = result {
3922 assert!(
3923 !matches!(err, crate::error::Error::NoServersAvailable(_)),
3924 "serve() must not return NoServersAvailable for empty lsp_servers config"
3925 );
3926 }
3927 }
3928
3929 #[test]
3930 fn test_convert_call_hierarchy_item_kind_is_numeric() {
3931 let item = lsp_types::CallHierarchyItem {
3932 name: "my_fn".to_string(),
3933 kind: lsp_types::SymbolKind::FUNCTION,
3934 tags: None,
3935 detail: None,
3936 uri: "file:///tmp/test.rs".parse().unwrap(),
3937 range: lsp_types::Range {
3938 start: lsp_types::Position {
3939 line: 0,
3940 character: 0,
3941 },
3942 end: lsp_types::Position {
3943 line: 0,
3944 character: 5,
3945 },
3946 },
3947 selection_range: lsp_types::Range {
3948 start: lsp_types::Position {
3949 line: 0,
3950 character: 0,
3951 },
3952 end: lsp_types::Position {
3953 line: 0,
3954 character: 5,
3955 },
3956 },
3957 data: None,
3958 };
3959 let result = convert_call_hierarchy_item(item);
3960 assert_eq!(result.kind, 12u32);
3962 assert_eq!(result.name, "my_fn");
3963 }
3964
3965 use std::process::Stdio;
3979
3980 use serde_json::Value as JsonValue;
3981 use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
3982 use tokio::process::{Child, ChildStdin, ChildStdout, Command};
3983 use tokio::time::timeout;
3984
3985 use crate::config::LspServerConfig;
3986 use crate::lsp::LspTransport;
3987
3988 struct FakeServer {
3989 _write_half: Child,
3990 _read_half: Child,
3991 read_half_stdin: ChildStdin,
3992 write_stdout: ChildStdout,
3993 }
3994
3995 fn fake_lsp_client() -> (LspClient, FakeServer) {
3996 let mut write_half = Command::new("cat")
3997 .stdin(Stdio::piped())
3998 .stdout(Stdio::piped())
3999 .kill_on_drop(true)
4000 .spawn()
4001 .unwrap();
4002 let write_stdin = write_half.stdin.take().unwrap();
4003 let write_stdout = write_half.stdout.take().unwrap();
4004
4005 let mut read_half = Command::new("cat")
4006 .stdin(Stdio::piped())
4007 .stdout(Stdio::piped())
4008 .kill_on_drop(true)
4009 .spawn()
4010 .unwrap();
4011 let read_stdout = read_half.stdout.take().unwrap();
4012 let read_stdin = read_half.stdin.take().unwrap();
4013
4014 let transport = LspTransport::new(write_stdin, read_stdout);
4015 let client = LspClient::from_transport(LspServerConfig::rust_analyzer(), transport);
4016
4017 (
4018 client,
4019 FakeServer {
4020 _write_half: write_half,
4021 _read_half: read_half,
4022 read_half_stdin: read_stdin,
4023 write_stdout,
4024 },
4025 )
4026 }
4027
4028 async fn read_framed_message(reader: &mut BufReader<&mut ChildStdout>) -> JsonValue {
4034 let mut content_length = None;
4035 let mut line = String::new();
4036 loop {
4037 line.clear();
4038 reader.read_line(&mut line).await.unwrap();
4039 if line == "\r\n" || line == "\n" {
4040 break;
4041 }
4042 if let Some((key, value)) = line.trim_end().split_once(':')
4043 && key.trim().eq_ignore_ascii_case("content-length")
4044 {
4045 content_length = Some(value.trim().parse::<usize>().unwrap());
4046 }
4047 }
4048 let mut buf = vec![0u8; content_length.unwrap()];
4049 reader.read_exact(&mut buf).await.unwrap();
4050 serde_json::from_slice(&buf).unwrap()
4051 }
4052
4053 async fn write_response(stdin: &mut ChildStdin, id: &JsonValue, result: JsonValue) {
4055 let message = serde_json::json!({
4056 "jsonrpc": "2.0",
4057 "id": id,
4058 "result": result,
4059 });
4060 let content = serde_json::to_string(&message).unwrap();
4061 let header = format!("Content-Length: {}\r\n\r\n", content.len());
4062 stdin.write_all(header.as_bytes()).await.unwrap();
4063 stdin.write_all(content.as_bytes()).await.unwrap();
4064 stdin.flush().await.unwrap();
4065 }
4066
4067 async fn write_error_response(
4070 stdin: &mut ChildStdin,
4071 id: &JsonValue,
4072 code: i64,
4073 message: &str,
4074 ) {
4075 let response = serde_json::json!({
4076 "jsonrpc": "2.0",
4077 "id": id,
4078 "error": {
4079 "code": code,
4080 "message": message,
4081 },
4082 });
4083 let content = serde_json::to_string(&response).unwrap();
4084 let header = format!("Content-Length: {}\r\n\r\n", content.len());
4085 stdin.write_all(header.as_bytes()).await.unwrap();
4086 stdin.write_all(content.as_bytes()).await.unwrap();
4087 stdin.flush().await.unwrap();
4088 }
4089
4090 #[tokio::test]
4091 async fn test_concurrent_handlers_on_different_files_do_not_serialize() {
4092 let dir = TempDir::new().unwrap();
4100 let mut extensions = HashMap::new();
4101 extensions.insert("aa".to_string(), "lang_a".to_string());
4102 extensions.insert("bb".to_string(), "lang_b".to_string());
4103
4104 let mut translator =
4105 Translator::new()
4106 .with_extensions(extensions)
4107 .with_router(ToolRouter::catch_all([
4108 (ServerId::from("lang_a"), "lang_a".to_string()),
4109 (ServerId::from("lang_b"), "lang_b".to_string()),
4110 ]));
4111 translator.set_workspace_roots(vec![dir.path().to_path_buf()]);
4112
4113 let (client_a, mut server_a) = fake_lsp_client();
4114 let (client_b, mut server_b) = fake_lsp_client();
4115 translator.register_client("lang_a".to_string(), client_a);
4116 translator.register_client("lang_b".to_string(), client_b);
4117
4118 let path_a = dir.path().join("file.aa");
4119 let path_b = dir.path().join("file.bb");
4120 fs::write(&path_a, "content a").unwrap();
4121 fs::write(&path_b, "content b").unwrap();
4122
4123 let translator = Arc::new(translator);
4124
4125 let slow = {
4129 let translator = Arc::clone(&translator);
4130 let path = path_a.to_string_lossy().to_string();
4131 tokio::spawn(async move { translator.handle_hover(path, 1, 1).await })
4132 };
4133
4134 let mut wire_a = BufReader::new(&mut server_a.write_stdout);
4138 let opened_a = read_framed_message(&mut wire_a).await;
4139 assert_eq!(opened_a["method"], "textDocument/didOpen");
4140 let hover_request_a = read_framed_message(&mut wire_a).await;
4141 assert_eq!(hover_request_a["method"], "textDocument/hover");
4142
4143 let fast = {
4145 let translator = Arc::clone(&translator);
4146 let path = path_b.to_string_lossy().to_string();
4147 tokio::spawn(async move { translator.handle_hover(path, 1, 1).await })
4148 };
4149
4150 let mut wire_b = BufReader::new(&mut server_b.write_stdout);
4151 let opened_b = read_framed_message(&mut wire_b).await;
4152 assert_eq!(opened_b["method"], "textDocument/didOpen");
4153 let hover_request_b = read_framed_message(&mut wire_b).await;
4154 assert_eq!(hover_request_b["method"], "textDocument/hover");
4155 write_response(
4156 &mut server_b.read_half_stdin,
4157 &hover_request_b["id"],
4158 JsonValue::Null,
4159 )
4160 .await;
4161
4162 let fast_result = timeout(Duration::from_secs(2), fast)
4163 .await
4164 .expect("fast call must not be blocked by the slow in-flight request")
4165 .unwrap();
4166 assert!(fast_result.is_ok());
4167
4168 assert!(
4169 !slow.is_finished(),
4170 "slow call should still be waiting on its (never-sent) response"
4171 );
4172 slow.abort();
4173 }
4174
4175 #[tokio::test]
4176 async fn test_concurrent_ensure_open_same_path_sends_single_did_open() {
4177 let dir = TempDir::new().unwrap();
4181 let mut extensions = HashMap::new();
4182 extensions.insert("aa".to_string(), "lang_a".to_string());
4183
4184 let mut translator =
4185 Translator::new()
4186 .with_extensions(extensions)
4187 .with_router(ToolRouter::catch_all([(
4188 ServerId::from("lang_a"),
4189 "lang_a".to_string(),
4190 )]));
4191 translator.set_workspace_roots(vec![dir.path().to_path_buf()]);
4192
4193 let (client, mut server) = fake_lsp_client();
4194 translator.register_client("lang_a".to_string(), client);
4195
4196 let path = dir.path().join("file.aa");
4197 fs::write(&path, "content").unwrap();
4198
4199 let concurrent_calls = 4;
4200
4201 let translator = Arc::new(translator);
4202 let path_str = path.to_string_lossy().to_string();
4203
4204 let handles: Vec<_> = (0..concurrent_calls)
4205 .map(|_| {
4206 let translator = Arc::clone(&translator);
4207 let path_str = path_str.clone();
4208 tokio::spawn(async move { translator.handle_hover(path_str, 1, 1).await })
4209 })
4210 .collect();
4211
4212 let mut wire = BufReader::new(&mut server.write_stdout);
4213 let opened = read_framed_message(&mut wire).await;
4214 assert_eq!(opened["method"], "textDocument/didOpen");
4215
4216 for _ in 0..concurrent_calls {
4217 let request = read_framed_message(&mut wire).await;
4218 assert_eq!(
4219 request["method"], "textDocument/hover",
4220 "no second didOpen must appear ahead of the hover requests"
4221 );
4222 write_response(&mut server.read_half_stdin, &request["id"], JsonValue::Null).await;
4223 }
4224
4225 for handle in handles {
4226 let result = timeout(Duration::from_secs(2), handle)
4227 .await
4228 .expect("handler call should not hang")
4229 .unwrap();
4230 assert!(result.is_ok());
4231 }
4232 }
4233
4234 #[tokio::test]
4239 async fn test_dispatch_routes_hover_and_diagnostics_to_different_servers() {
4240 let dir = TempDir::new().unwrap();
4241 let mut extensions = HashMap::new();
4242 extensions.insert("py".to_string(), "python".to_string());
4243
4244 let pyright_id = ServerId::from("pyright");
4245 let pylsp_id = ServerId::from("pylsp");
4246 let configs = vec![
4247 LspServerConfig {
4248 language_id: "python".to_string(),
4249 command: "pyright-langserver".to_string(),
4250 args: vec![],
4251 env: HashMap::new(),
4252 file_patterns: vec![],
4253 initialization_options: None,
4254 timeout_seconds: 30,
4255 heuristics: None,
4256 name: Some("pyright".to_string()),
4257 handles: Some(vec![ToolKind::Hover]),
4258 },
4259 LspServerConfig {
4260 language_id: "python".to_string(),
4261 command: "pylsp".to_string(),
4262 args: vec![],
4263 env: HashMap::new(),
4264 file_patterns: vec![],
4265 initialization_options: None,
4266 timeout_seconds: 30,
4267 heuristics: None,
4268 name: Some("pylsp".to_string()),
4269 handles: Some(vec![ToolKind::Diagnostics]),
4270 },
4271 ];
4272 let router = ToolRouter::from_configs(&configs).unwrap();
4273
4274 let mut translator = Translator::new()
4275 .with_extensions(extensions)
4276 .with_router(router);
4277 translator.set_workspace_roots(vec![dir.path().to_path_buf()]);
4278
4279 let (client_pyright, mut server_pyright) = fake_lsp_client();
4280 let (client_pylsp, mut server_pylsp) = fake_lsp_client();
4281 translator.register_client(pyright_id, client_pyright);
4282 translator.register_client(pylsp_id, client_pylsp);
4283
4284 let path = dir.path().join("main.py");
4285 fs::write(&path, "x = 1").unwrap();
4286 let path_str = path.to_string_lossy().to_string();
4287
4288 let translator = Arc::new(translator);
4289
4290 let rename_result = translator
4293 .handle_rename(path_str.clone(), 1, 1, "renamed".to_string())
4294 .await;
4295 assert!(
4296 matches!(
4297 rename_result,
4298 Err(Error::NoServerForTool {
4299 tool: ToolKind::Rename,
4300 ..
4301 })
4302 ),
4303 "expected NoServerForTool for rename, got {rename_result:?}"
4304 );
4305
4306 let hover = {
4308 let translator = Arc::clone(&translator);
4309 let path_str = path_str.clone();
4310 tokio::spawn(async move { translator.handle_hover(path_str, 1, 1).await })
4311 };
4312 let mut wire_pyright = BufReader::new(&mut server_pyright.write_stdout);
4313 let opened = read_framed_message(&mut wire_pyright).await;
4314 assert_eq!(opened["method"], "textDocument/didOpen");
4315 let hover_request = read_framed_message(&mut wire_pyright).await;
4316 assert_eq!(hover_request["method"], "textDocument/hover");
4317 write_response(
4318 &mut server_pyright.read_half_stdin,
4319 &hover_request["id"],
4320 JsonValue::Null,
4321 )
4322 .await;
4323 hover
4324 .await
4325 .unwrap()
4326 .expect("hover routed to pyright must succeed");
4327
4328 let diagnostics = {
4332 let translator = Arc::clone(&translator);
4333 let notification_cache = Arc::new(Mutex::new(NotificationCache::new()));
4334 tokio::spawn(async move {
4335 translator
4336 .handle_diagnostics(path_str, ¬ification_cache)
4337 .await
4338 })
4339 };
4340 let mut wire_pylsp = BufReader::new(&mut server_pylsp.write_stdout);
4341 let opened = read_framed_message(&mut wire_pylsp).await;
4342 assert_eq!(opened["method"], "textDocument/didOpen");
4343 let diag_request = read_framed_message(&mut wire_pylsp).await;
4344 assert_eq!(diag_request["method"], "textDocument/diagnostic");
4345 diagnostics.abort();
4348 }
4349
4350 #[tokio::test]
4355 async fn test_handle_diagnostics_pull_error_falls_back_to_nonempty_cache() {
4356 let dir = TempDir::new().unwrap();
4357 let mut extensions = HashMap::new();
4358 extensions.insert("rs".to_string(), "rust".to_string());
4359
4360 let mut translator =
4361 Translator::new()
4362 .with_extensions(extensions)
4363 .with_router(ToolRouter::catch_all([(
4364 ServerId::from("rust"),
4365 "rust".to_string(),
4366 )]));
4367 translator.set_workspace_roots(vec![dir.path().to_path_buf()]);
4368
4369 let (client, mut server) = fake_lsp_client();
4370 translator.register_client("rust".to_string(), client);
4371
4372 let path = dir.path().join("lib.rs");
4373 fs::write(&path, "fn main() {}").unwrap();
4374 let path_str = path.to_string_lossy().to_string();
4375
4376 let canonical = path.canonicalize().unwrap();
4380 let uri = path_to_uri(&canonical);
4381 let notification_cache = Mutex::new(NotificationCache::new());
4382 {
4383 let mut cache = notification_cache.lock().await;
4384 cache.store_diagnostics(
4385 &uri,
4386 Some(1),
4387 vec![lsp_diag(
4388 0,
4389 4,
4390 lsp_types::DiagnosticSeverity::WARNING,
4391 "unused import: `std::fmt`",
4392 None,
4393 )],
4394 );
4395 }
4396
4397 let translator = Arc::new(translator);
4398 let handle = {
4399 let translator = Arc::clone(&translator);
4400 tokio::spawn(async move {
4401 translator
4402 .handle_diagnostics(path_str, ¬ification_cache)
4403 .await
4404 })
4405 };
4406
4407 let mut wire = BufReader::new(&mut server.write_stdout);
4408 let opened = read_framed_message(&mut wire).await;
4409 assert_eq!(opened["method"], "textDocument/didOpen");
4410 let diag_request = read_framed_message(&mut wire).await;
4411 assert_eq!(diag_request["method"], "textDocument/diagnostic");
4412 write_error_response(
4413 &mut server.read_half_stdin,
4414 &diag_request["id"],
4415 -32601,
4416 "method not found",
4417 )
4418 .await;
4419
4420 let result = timeout(Duration::from_secs(2), handle)
4421 .await
4422 .expect("handler call should not hang")
4423 .unwrap();
4424
4425 let diagnostics = result.expect("cache-only fallback should succeed despite pull error");
4426 assert_eq!(diagnostics.diagnostics.len(), 1);
4427 assert_eq!(
4428 diagnostics.diagnostics[0].message,
4429 "unused import: `std::fmt`"
4430 );
4431 }
4432
4433 #[tokio::test]
4436 async fn test_handle_diagnostics_pull_error_and_empty_cache_propagates_error() {
4437 let dir = TempDir::new().unwrap();
4438 let mut extensions = HashMap::new();
4439 extensions.insert("rs".to_string(), "rust".to_string());
4440
4441 let mut translator =
4442 Translator::new()
4443 .with_extensions(extensions)
4444 .with_router(ToolRouter::catch_all([(
4445 ServerId::from("rust"),
4446 "rust".to_string(),
4447 )]));
4448 translator.set_workspace_roots(vec![dir.path().to_path_buf()]);
4449
4450 let (client, mut server) = fake_lsp_client();
4451 translator.register_client("rust".to_string(), client);
4452
4453 let path = dir.path().join("lib.rs");
4454 fs::write(&path, "fn main() {}").unwrap();
4455 let path_str = path.to_string_lossy().to_string();
4456
4457 let notification_cache = Mutex::new(NotificationCache::new());
4458
4459 let translator = Arc::new(translator);
4460 let handle = {
4461 let translator = Arc::clone(&translator);
4462 tokio::spawn(async move {
4463 translator
4464 .handle_diagnostics(path_str, ¬ification_cache)
4465 .await
4466 })
4467 };
4468
4469 let mut wire = BufReader::new(&mut server.write_stdout);
4470 let opened = read_framed_message(&mut wire).await;
4471 assert_eq!(opened["method"], "textDocument/didOpen");
4472 let diag_request = read_framed_message(&mut wire).await;
4473 assert_eq!(diag_request["method"], "textDocument/diagnostic");
4474 write_error_response(
4475 &mut server.read_half_stdin,
4476 &diag_request["id"],
4477 -32601,
4478 "method not found",
4479 )
4480 .await;
4481
4482 let result = timeout(Duration::from_secs(2), handle)
4483 .await
4484 .expect("handler call should not hang")
4485 .unwrap();
4486
4487 assert!(
4488 result.is_err(),
4489 "pull error with no cache data must propagate, got {result:?}"
4490 );
4491 }
4492}