1use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5
6use lsp_types::{
7 CallHierarchyIncomingCall, CallHierarchyIncomingCallsParams, CallHierarchyItem,
8 CallHierarchyOutgoingCall, CallHierarchyOutgoingCallsParams,
9 CallHierarchyPrepareParams as LspCallHierarchyPrepareParams, CompletionParams,
10 CompletionTriggerKind, DocumentFormattingParams, DocumentSymbol, DocumentSymbolParams,
11 FormattingOptions, GotoDefinitionParams, Hover, HoverContents, HoverParams as LspHoverParams,
12 MarkedString, PartialResultParams, ReferenceContext, ReferenceParams,
13 RenameParams as LspRenameParams, TextDocumentIdentifier, TextDocumentPositionParams,
14 WorkDoneProgressParams, WorkspaceEdit, WorkspaceSymbolParams as LspWorkspaceSymbolParams,
15};
16use serde::{Deserialize, Serialize};
17use tokio::time::Duration;
18
19use super::DocumentTracker;
20use super::state::detect_language;
21use crate::bridge::encoding::mcp_to_lsp_position;
22use crate::error::{Error, Result};
23use crate::lsp::LspClient;
24
25#[derive(Debug)]
27pub struct Translator {
28 lsp_clients: HashMap<String, LspClient>,
30 document_tracker: DocumentTracker,
32 workspace_roots: Vec<PathBuf>,
34}
35
36impl Translator {
37 #[must_use]
39 pub fn new() -> Self {
40 Self {
41 lsp_clients: HashMap::new(),
42 document_tracker: DocumentTracker::new(),
43 workspace_roots: vec![],
44 }
45 }
46
47 pub fn set_workspace_roots(&mut self, roots: Vec<PathBuf>) {
49 self.workspace_roots = roots;
50 }
51
52 pub fn register_client(&mut self, language_id: String, client: LspClient) {
54 self.lsp_clients.insert(language_id, client);
55 }
56
57 #[must_use]
59 pub const fn document_tracker(&self) -> &DocumentTracker {
60 &self.document_tracker
61 }
62
63 pub const fn document_tracker_mut(&mut self) -> &mut DocumentTracker {
65 &mut self.document_tracker
66 }
67
68 }
73
74impl Default for Translator {
75 fn default() -> Self {
76 Self::new()
77 }
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct Position2D {
83 pub line: u32,
85 pub character: u32,
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct Range {
92 pub start: Position2D,
94 pub end: Position2D,
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct Location {
101 pub uri: String,
103 pub range: Range,
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct HoverResult {
110 pub contents: String,
112 pub range: Option<Range>,
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize)]
118pub struct DefinitionResult {
119 pub locations: Vec<Location>,
121}
122
123#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct ReferencesResult {
126 pub locations: Vec<Location>,
128}
129
130#[derive(Debug, Clone, Serialize, Deserialize)]
132#[serde(rename_all = "lowercase")]
133pub enum DiagnosticSeverity {
134 Error,
136 Warning,
138 Information,
140 Hint,
142}
143
144#[derive(Debug, Clone, Serialize, Deserialize)]
146pub struct Diagnostic {
147 pub range: Range,
149 pub severity: DiagnosticSeverity,
151 pub message: String,
153 pub code: Option<String>,
155}
156
157#[derive(Debug, Clone, Serialize, Deserialize)]
159pub struct DiagnosticsResult {
160 pub diagnostics: Vec<Diagnostic>,
162}
163
164#[derive(Debug, Clone, Serialize, Deserialize)]
166pub struct TextEdit {
167 pub range: Range,
169 pub new_text: String,
171}
172
173#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct DocumentChanges {
176 pub uri: String,
178 pub edits: Vec<TextEdit>,
180}
181
182#[derive(Debug, Clone, Serialize, Deserialize)]
184pub struct RenameResult {
185 pub changes: Vec<DocumentChanges>,
187}
188
189#[derive(Debug, Clone, Serialize, Deserialize)]
191pub struct Completion {
192 pub label: String,
194 pub kind: Option<String>,
196 pub detail: Option<String>,
198 pub documentation: Option<String>,
200}
201
202#[derive(Debug, Clone, Serialize, Deserialize)]
204pub struct CompletionsResult {
205 pub items: Vec<Completion>,
207}
208
209#[derive(Debug, Clone, Serialize, Deserialize)]
211pub struct Symbol {
212 pub name: String,
214 pub kind: String,
216 pub range: Range,
218 pub selection_range: Range,
220 #[serde(skip_serializing_if = "Option::is_none")]
222 pub children: Option<Vec<Self>>,
223}
224
225#[derive(Debug, Clone, Serialize, Deserialize)]
227pub struct DocumentSymbolsResult {
228 pub symbols: Vec<Symbol>,
230}
231
232#[derive(Debug, Clone, Serialize, Deserialize)]
234pub struct FormatDocumentResult {
235 pub edits: Vec<TextEdit>,
237}
238
239#[derive(Debug, Clone, Serialize, Deserialize)]
241pub struct WorkspaceSymbol {
242 pub name: String,
244 pub kind: String,
246 pub location: Location,
248 #[serde(skip_serializing_if = "Option::is_none")]
250 pub container_name: Option<String>,
251}
252
253#[derive(Debug, Clone, Serialize, Deserialize)]
255pub struct WorkspaceSymbolResult {
256 pub symbols: Vec<WorkspaceSymbol>,
258}
259
260#[derive(Debug, Clone, Serialize, Deserialize)]
262pub struct CodeAction {
263 pub title: String,
265 #[serde(skip_serializing_if = "Option::is_none")]
267 pub kind: Option<String>,
268 #[serde(skip_serializing_if = "Vec::is_empty", default)]
270 pub diagnostics: Vec<Diagnostic>,
271 #[serde(skip_serializing_if = "Option::is_none")]
273 pub edit: Option<WorkspaceEditDescription>,
274 #[serde(skip_serializing_if = "Option::is_none")]
276 pub command: Option<CommandDescription>,
277 #[serde(default)]
279 pub is_preferred: bool,
280}
281
282#[derive(Debug, Clone, Serialize, Deserialize)]
284pub struct WorkspaceEditDescription {
285 pub changes: Vec<DocumentChanges>,
287}
288
289#[derive(Debug, Clone, Serialize, Deserialize)]
291pub struct CommandDescription {
292 pub title: String,
294 pub command: String,
296 #[serde(skip_serializing_if = "Vec::is_empty", default)]
298 pub arguments: Vec<serde_json::Value>,
299}
300
301#[derive(Debug, Clone, Serialize, Deserialize)]
303pub struct CodeActionsResult {
304 pub actions: Vec<CodeAction>,
306}
307
308#[derive(Debug, Clone, Serialize, Deserialize)]
310pub struct CallHierarchyItemResult {
311 pub name: String,
313 pub kind: String,
315 #[serde(skip_serializing_if = "Option::is_none")]
317 pub detail: Option<String>,
318 pub uri: String,
320 pub range: Range,
322 pub selection_range: Range,
324 #[serde(skip_serializing_if = "Option::is_none")]
326 pub data: Option<serde_json::Value>,
327}
328
329#[derive(Debug, Clone, Serialize, Deserialize)]
331pub struct CallHierarchyPrepareResult {
332 pub items: Vec<CallHierarchyItemResult>,
334}
335
336#[derive(Debug, Clone, Serialize, Deserialize)]
338pub struct IncomingCall {
339 pub from: CallHierarchyItemResult,
341 pub from_ranges: Vec<Range>,
343}
344
345#[derive(Debug, Clone, Serialize, Deserialize)]
347pub struct IncomingCallsResult {
348 pub calls: Vec<IncomingCall>,
350}
351
352#[derive(Debug, Clone, Serialize, Deserialize)]
354pub struct OutgoingCall {
355 pub to: CallHierarchyItemResult,
357 pub from_ranges: Vec<Range>,
359}
360
361#[derive(Debug, Clone, Serialize, Deserialize)]
363pub struct OutgoingCallsResult {
364 pub calls: Vec<OutgoingCall>,
366}
367
368const MAX_POSITION_VALUE: u32 = 1_000_000;
370const MAX_RANGE_LINES: u32 = 10_000;
372
373impl Translator {
374 fn validate_path(&self, path: &Path) -> Result<PathBuf> {
380 let canonical = path.canonicalize().map_err(|e| Error::FileIo {
381 path: path.to_path_buf(),
382 source: e,
383 })?;
384
385 if self.workspace_roots.is_empty() {
387 return Ok(canonical);
388 }
389
390 for root in &self.workspace_roots {
392 if let Ok(canonical_root) = root.canonicalize() {
393 if canonical.starts_with(&canonical_root) {
394 return Ok(canonical);
395 }
396 }
397 }
398
399 Err(Error::PathOutsideWorkspace(path.to_path_buf()))
400 }
401
402 fn get_client_for_file(&self, path: &Path) -> Result<LspClient> {
404 let language_id = detect_language(path);
405 self.lsp_clients
406 .get(&language_id)
407 .cloned()
408 .ok_or(Error::NoServerForLanguage(language_id))
409 }
410
411 fn parse_file_uri(&self, uri: &lsp_types::Uri) -> Result<PathBuf> {
419 let uri_str = uri.as_str();
420
421 if !uri_str.starts_with("file://") {
423 return Err(Error::InvalidToolParams(format!(
424 "Invalid URI scheme, expected file:// but got: {uri_str}"
425 )));
426 }
427
428 let path_str = &uri_str["file://".len()..];
430
431 #[cfg(windows)]
434 let path_str = if path_str.len() >= 3
435 && path_str.starts_with('/')
436 && path_str.chars().nth(2) == Some(':')
437 {
438 &path_str[1..]
439 } else {
440 path_str
441 };
442
443 let path = PathBuf::from(path_str);
444
445 self.validate_path(&path)
447 }
448
449 pub async fn handle_hover(
455 &mut self,
456 file_path: String,
457 line: u32,
458 character: u32,
459 ) -> Result<HoverResult> {
460 let path = PathBuf::from(&file_path);
461 let validated_path = self.validate_path(&path)?;
462 let client = self.get_client_for_file(&validated_path)?;
463 let uri = self
464 .document_tracker
465 .ensure_open(&validated_path, &client)
466 .await?;
467 let lsp_position = mcp_to_lsp_position(line, character);
468
469 let params = LspHoverParams {
470 text_document_position_params: TextDocumentPositionParams {
471 text_document: TextDocumentIdentifier { uri },
472 position: lsp_position,
473 },
474 work_done_progress_params: WorkDoneProgressParams::default(),
475 };
476
477 let timeout_duration = Duration::from_secs(30);
478 let response: Option<Hover> = client
479 .request("textDocument/hover", params, timeout_duration)
480 .await?;
481
482 let result = match response {
483 Some(hover) => {
484 let contents = extract_hover_contents(hover.contents);
485 let range = hover.range.map(normalize_range);
486 HoverResult { contents, range }
487 }
488 None => HoverResult {
489 contents: "No hover information available".to_string(),
490 range: None,
491 },
492 };
493
494 Ok(result)
495 }
496
497 pub async fn handle_definition(
503 &mut self,
504 file_path: String,
505 line: u32,
506 character: u32,
507 ) -> Result<DefinitionResult> {
508 let path = PathBuf::from(&file_path);
509 let validated_path = self.validate_path(&path)?;
510 let client = self.get_client_for_file(&validated_path)?;
511 let uri = self
512 .document_tracker
513 .ensure_open(&validated_path, &client)
514 .await?;
515 let lsp_position = mcp_to_lsp_position(line, character);
516
517 let params = GotoDefinitionParams {
518 text_document_position_params: TextDocumentPositionParams {
519 text_document: TextDocumentIdentifier { uri },
520 position: lsp_position,
521 },
522 work_done_progress_params: WorkDoneProgressParams::default(),
523 partial_result_params: PartialResultParams::default(),
524 };
525
526 let timeout_duration = Duration::from_secs(30);
527 let response: Option<lsp_types::GotoDefinitionResponse> = client
528 .request("textDocument/definition", params, timeout_duration)
529 .await?;
530
531 let locations = match response {
532 Some(lsp_types::GotoDefinitionResponse::Scalar(loc)) => vec![loc],
533 Some(lsp_types::GotoDefinitionResponse::Array(locs)) => locs,
534 Some(lsp_types::GotoDefinitionResponse::Link(links)) => links
535 .into_iter()
536 .map(|link| lsp_types::Location {
537 uri: link.target_uri,
538 range: link.target_selection_range,
539 })
540 .collect(),
541 None => vec![],
542 };
543
544 let result = DefinitionResult {
545 locations: locations
546 .into_iter()
547 .map(|loc| Location {
548 uri: loc.uri.to_string(),
549 range: normalize_range(loc.range),
550 })
551 .collect(),
552 };
553
554 Ok(result)
555 }
556
557 pub async fn handle_references(
563 &mut self,
564 file_path: String,
565 line: u32,
566 character: u32,
567 include_declaration: bool,
568 ) -> Result<ReferencesResult> {
569 let path = PathBuf::from(&file_path);
570 let validated_path = self.validate_path(&path)?;
571 let client = self.get_client_for_file(&validated_path)?;
572 let uri = self
573 .document_tracker
574 .ensure_open(&validated_path, &client)
575 .await?;
576 let lsp_position = mcp_to_lsp_position(line, character);
577
578 let params = ReferenceParams {
579 text_document_position: TextDocumentPositionParams {
580 text_document: TextDocumentIdentifier { uri },
581 position: lsp_position,
582 },
583 work_done_progress_params: WorkDoneProgressParams::default(),
584 partial_result_params: PartialResultParams::default(),
585 context: ReferenceContext {
586 include_declaration,
587 },
588 };
589
590 let timeout_duration = Duration::from_secs(30);
591 let response: Option<Vec<lsp_types::Location>> = client
592 .request("textDocument/references", params, timeout_duration)
593 .await?;
594
595 let locations = response.unwrap_or_default();
596
597 let result = ReferencesResult {
598 locations: locations
599 .into_iter()
600 .map(|loc| Location {
601 uri: loc.uri.to_string(),
602 range: normalize_range(loc.range),
603 })
604 .collect(),
605 };
606
607 Ok(result)
608 }
609
610 pub async fn handle_diagnostics(&mut self, file_path: String) -> Result<DiagnosticsResult> {
616 let path = PathBuf::from(&file_path);
617 let validated_path = self.validate_path(&path)?;
618 let client = self.get_client_for_file(&validated_path)?;
619 let uri = self
620 .document_tracker
621 .ensure_open(&validated_path, &client)
622 .await?;
623
624 let params = lsp_types::DocumentDiagnosticParams {
625 text_document: TextDocumentIdentifier { uri },
626 identifier: None,
627 previous_result_id: None,
628 work_done_progress_params: WorkDoneProgressParams::default(),
629 partial_result_params: PartialResultParams::default(),
630 };
631
632 let timeout_duration = Duration::from_secs(30);
633 let response: lsp_types::DocumentDiagnosticReportResult = client
634 .request("textDocument/diagnostic", params, timeout_duration)
635 .await?;
636
637 let diagnostics = match response {
638 lsp_types::DocumentDiagnosticReportResult::Report(report) => match report {
639 lsp_types::DocumentDiagnosticReport::Full(full) => {
640 full.full_document_diagnostic_report.items
641 }
642 lsp_types::DocumentDiagnosticReport::Unchanged(_) => vec![],
643 },
644 lsp_types::DocumentDiagnosticReportResult::Partial(_) => vec![],
645 };
646
647 let result = DiagnosticsResult {
648 diagnostics: diagnostics
649 .into_iter()
650 .map(|diag| Diagnostic {
651 range: normalize_range(diag.range),
652 severity: match diag.severity {
653 Some(lsp_types::DiagnosticSeverity::ERROR) => DiagnosticSeverity::Error,
654 Some(lsp_types::DiagnosticSeverity::WARNING) => DiagnosticSeverity::Warning,
655 Some(lsp_types::DiagnosticSeverity::INFORMATION) => {
656 DiagnosticSeverity::Information
657 }
658 Some(lsp_types::DiagnosticSeverity::HINT) => DiagnosticSeverity::Hint,
659 _ => DiagnosticSeverity::Information,
660 },
661 message: diag.message,
662 code: diag.code.map(|c| match c {
663 lsp_types::NumberOrString::Number(n) => n.to_string(),
664 lsp_types::NumberOrString::String(s) => s,
665 }),
666 })
667 .collect(),
668 };
669
670 Ok(result)
671 }
672
673 pub async fn handle_rename(
679 &mut self,
680 file_path: String,
681 line: u32,
682 character: u32,
683 new_name: String,
684 ) -> Result<RenameResult> {
685 let path = PathBuf::from(&file_path);
686 let validated_path = self.validate_path(&path)?;
687 let client = self.get_client_for_file(&validated_path)?;
688 let uri = self
689 .document_tracker
690 .ensure_open(&validated_path, &client)
691 .await?;
692 let lsp_position = mcp_to_lsp_position(line, character);
693
694 let params = LspRenameParams {
695 text_document_position: TextDocumentPositionParams {
696 text_document: TextDocumentIdentifier { uri },
697 position: lsp_position,
698 },
699 new_name,
700 work_done_progress_params: WorkDoneProgressParams::default(),
701 };
702
703 let timeout_duration = Duration::from_secs(30);
704 let response: Option<WorkspaceEdit> = client
705 .request("textDocument/rename", params, timeout_duration)
706 .await?;
707
708 let changes = if let Some(edit) = response {
709 let mut result_changes = Vec::new();
710
711 if let Some(changes_map) = edit.changes {
712 for (uri, edits) in changes_map {
713 result_changes.push(DocumentChanges {
714 uri: uri.to_string(),
715 edits: edits
716 .into_iter()
717 .map(|edit| TextEdit {
718 range: normalize_range(edit.range),
719 new_text: edit.new_text,
720 })
721 .collect(),
722 });
723 }
724 }
725
726 result_changes
727 } else {
728 vec![]
729 };
730
731 Ok(RenameResult { changes })
732 }
733
734 pub async fn handle_completions(
740 &mut self,
741 file_path: String,
742 line: u32,
743 character: u32,
744 trigger: Option<String>,
745 ) -> Result<CompletionsResult> {
746 let path = PathBuf::from(&file_path);
747 let validated_path = self.validate_path(&path)?;
748 let client = self.get_client_for_file(&validated_path)?;
749 let uri = self
750 .document_tracker
751 .ensure_open(&validated_path, &client)
752 .await?;
753 let lsp_position = mcp_to_lsp_position(line, character);
754
755 let context = trigger.map(|trigger_char| lsp_types::CompletionContext {
756 trigger_kind: CompletionTriggerKind::TRIGGER_CHARACTER,
757 trigger_character: Some(trigger_char),
758 });
759
760 let params = CompletionParams {
761 text_document_position: TextDocumentPositionParams {
762 text_document: TextDocumentIdentifier { uri },
763 position: lsp_position,
764 },
765 work_done_progress_params: WorkDoneProgressParams::default(),
766 partial_result_params: PartialResultParams::default(),
767 context,
768 };
769
770 let timeout_duration = Duration::from_secs(10);
771 let response: Option<lsp_types::CompletionResponse> = client
772 .request("textDocument/completion", params, timeout_duration)
773 .await?;
774
775 let items = match response {
776 Some(lsp_types::CompletionResponse::Array(items)) => items,
777 Some(lsp_types::CompletionResponse::List(list)) => list.items,
778 None => vec![],
779 };
780
781 let result = CompletionsResult {
782 items: items
783 .into_iter()
784 .map(|item| Completion {
785 label: item.label,
786 kind: item.kind.map(|k| format!("{k:?}")),
787 detail: item.detail,
788 documentation: item.documentation.map(|doc| match doc {
789 lsp_types::Documentation::String(s) => s,
790 lsp_types::Documentation::MarkupContent(m) => m.value,
791 }),
792 })
793 .collect(),
794 };
795
796 Ok(result)
797 }
798
799 pub async fn handle_document_symbols(
805 &mut self,
806 file_path: String,
807 ) -> Result<DocumentSymbolsResult> {
808 let path = PathBuf::from(&file_path);
809 let validated_path = self.validate_path(&path)?;
810 let client = self.get_client_for_file(&validated_path)?;
811 let uri = self
812 .document_tracker
813 .ensure_open(&validated_path, &client)
814 .await?;
815
816 let params = DocumentSymbolParams {
817 text_document: TextDocumentIdentifier { uri },
818 work_done_progress_params: WorkDoneProgressParams::default(),
819 partial_result_params: PartialResultParams::default(),
820 };
821
822 let timeout_duration = Duration::from_secs(30);
823 let response: Option<lsp_types::DocumentSymbolResponse> = client
824 .request("textDocument/documentSymbol", params, timeout_duration)
825 .await?;
826
827 let symbols = match response {
828 Some(lsp_types::DocumentSymbolResponse::Flat(symbols)) => symbols
829 .into_iter()
830 .map(|sym| Symbol {
831 name: sym.name,
832 kind: format!("{:?}", sym.kind),
833 range: normalize_range(sym.location.range),
834 selection_range: normalize_range(sym.location.range),
835 children: None,
836 })
837 .collect(),
838 Some(lsp_types::DocumentSymbolResponse::Nested(symbols)) => {
839 symbols.into_iter().map(convert_document_symbol).collect()
840 }
841 None => vec![],
842 };
843
844 Ok(DocumentSymbolsResult { symbols })
845 }
846
847 pub async fn handle_format_document(
853 &mut self,
854 file_path: String,
855 tab_size: u32,
856 insert_spaces: bool,
857 ) -> Result<FormatDocumentResult> {
858 let path = PathBuf::from(&file_path);
859 let validated_path = self.validate_path(&path)?;
860 let client = self.get_client_for_file(&validated_path)?;
861 let uri = self
862 .document_tracker
863 .ensure_open(&validated_path, &client)
864 .await?;
865
866 let params = DocumentFormattingParams {
867 text_document: TextDocumentIdentifier { uri },
868 options: FormattingOptions {
869 tab_size,
870 insert_spaces,
871 ..Default::default()
872 },
873 work_done_progress_params: WorkDoneProgressParams::default(),
874 };
875
876 let timeout_duration = Duration::from_secs(30);
877 let response: Option<Vec<lsp_types::TextEdit>> = client
878 .request("textDocument/formatting", params, timeout_duration)
879 .await?;
880
881 let edits = response.unwrap_or_default();
882
883 let result = FormatDocumentResult {
884 edits: edits
885 .into_iter()
886 .map(|edit| TextEdit {
887 range: normalize_range(edit.range),
888 new_text: edit.new_text,
889 })
890 .collect(),
891 };
892
893 Ok(result)
894 }
895
896 pub async fn handle_workspace_symbol(
902 &mut self,
903 query: String,
904 kind_filter: Option<String>,
905 limit: u32,
906 ) -> Result<WorkspaceSymbolResult> {
907 const MAX_QUERY_LENGTH: usize = 1000;
908 const VALID_SYMBOL_KINDS: &[&str] = &[
909 "File",
910 "Module",
911 "Namespace",
912 "Package",
913 "Class",
914 "Method",
915 "Property",
916 "Field",
917 "Constructor",
918 "Enum",
919 "Interface",
920 "Function",
921 "Variable",
922 "Constant",
923 "String",
924 "Number",
925 "Boolean",
926 "Array",
927 "Object",
928 "Key",
929 "Null",
930 "EnumMember",
931 "Struct",
932 "Event",
933 "Operator",
934 "TypeParameter",
935 ];
936
937 if query.len() > MAX_QUERY_LENGTH {
939 return Err(Error::InvalidToolParams(format!(
940 "Query too long: {} chars (max {MAX_QUERY_LENGTH})",
941 query.len()
942 )));
943 }
944
945 if let Some(ref kind) = kind_filter {
947 if !VALID_SYMBOL_KINDS
948 .iter()
949 .any(|k| k.eq_ignore_ascii_case(kind))
950 {
951 return Err(Error::InvalidToolParams(format!(
952 "Invalid kind_filter: '{kind}'. Valid values: {VALID_SYMBOL_KINDS:?}"
953 )));
954 }
955 }
956
957 let client = self
959 .lsp_clients
960 .values()
961 .next()
962 .cloned()
963 .ok_or(Error::NoServerConfigured)?;
964
965 let params = LspWorkspaceSymbolParams {
966 query,
967 work_done_progress_params: WorkDoneProgressParams::default(),
968 partial_result_params: PartialResultParams::default(),
969 };
970
971 let timeout_duration = Duration::from_secs(30);
972 let response: Option<Vec<lsp_types::SymbolInformation>> = client
973 .request("workspace/symbol", params, timeout_duration)
974 .await?;
975
976 let mut symbols: Vec<WorkspaceSymbol> = response
977 .unwrap_or_default()
978 .into_iter()
979 .map(|sym| WorkspaceSymbol {
980 name: sym.name,
981 kind: format!("{:?}", sym.kind),
982 location: Location {
983 uri: sym.location.uri.to_string(),
984 range: normalize_range(sym.location.range),
985 },
986 container_name: sym.container_name,
987 })
988 .collect();
989
990 if let Some(kind) = kind_filter {
992 symbols.retain(|s| s.kind.eq_ignore_ascii_case(&kind));
993 }
994
995 symbols.truncate(limit as usize);
997
998 Ok(WorkspaceSymbolResult { symbols })
999 }
1000
1001 pub async fn handle_code_actions(
1007 &mut self,
1008 file_path: String,
1009 start_line: u32,
1010 start_character: u32,
1011 end_line: u32,
1012 end_character: u32,
1013 kind_filter: Option<String>,
1014 ) -> Result<CodeActionsResult> {
1015 const VALID_ACTION_KINDS: &[&str] = &[
1016 "quickfix",
1017 "refactor",
1018 "refactor.extract",
1019 "refactor.inline",
1020 "refactor.rewrite",
1021 "source",
1022 "source.organizeImports",
1023 ];
1024
1025 if let Some(ref kind) = kind_filter {
1027 if !VALID_ACTION_KINDS
1028 .iter()
1029 .any(|k| k.eq_ignore_ascii_case(kind))
1030 {
1031 return Err(Error::InvalidToolParams(format!(
1032 "Invalid kind_filter: '{kind}'. Valid values: {VALID_ACTION_KINDS:?}"
1033 )));
1034 }
1035 }
1036
1037 if start_line < 1 || start_character < 1 || end_line < 1 || end_character < 1 {
1039 return Err(Error::InvalidToolParams(
1040 "Line and character positions must be >= 1".to_string(),
1041 ));
1042 }
1043
1044 if start_line > MAX_POSITION_VALUE
1046 || start_character > MAX_POSITION_VALUE
1047 || end_line > MAX_POSITION_VALUE
1048 || end_character > MAX_POSITION_VALUE
1049 {
1050 return Err(Error::InvalidToolParams(format!(
1051 "Position values must be <= {MAX_POSITION_VALUE}"
1052 )));
1053 }
1054
1055 if end_line.saturating_sub(start_line) > MAX_RANGE_LINES {
1057 return Err(Error::InvalidToolParams(format!(
1058 "Range size must be <= {MAX_RANGE_LINES} lines"
1059 )));
1060 }
1061
1062 if start_line > end_line || (start_line == end_line && start_character > end_character) {
1063 return Err(Error::InvalidToolParams(
1064 "Start position must be before or equal to end position".to_string(),
1065 ));
1066 }
1067
1068 let path = PathBuf::from(&file_path);
1069 let validated_path = self.validate_path(&path)?;
1070 let client = self.get_client_for_file(&validated_path)?;
1071 let uri = self
1072 .document_tracker
1073 .ensure_open(&validated_path, &client)
1074 .await?;
1075
1076 let range = lsp_types::Range {
1077 start: mcp_to_lsp_position(start_line, start_character),
1078 end: mcp_to_lsp_position(end_line, end_character),
1079 };
1080
1081 let only = kind_filter.map(|k| vec![lsp_types::CodeActionKind::from(k)]);
1083
1084 let params = lsp_types::CodeActionParams {
1085 text_document: TextDocumentIdentifier { uri },
1086 range,
1087 context: lsp_types::CodeActionContext {
1088 diagnostics: vec![],
1089 only,
1090 trigger_kind: Some(lsp_types::CodeActionTriggerKind::INVOKED),
1091 },
1092 work_done_progress_params: WorkDoneProgressParams::default(),
1093 partial_result_params: PartialResultParams::default(),
1094 };
1095
1096 let timeout_duration = Duration::from_secs(30);
1097 let response: Option<lsp_types::CodeActionResponse> = client
1098 .request("textDocument/codeAction", params, timeout_duration)
1099 .await?;
1100
1101 let response_vec = response.unwrap_or_default();
1102 let mut actions = Vec::with_capacity(response_vec.len());
1103
1104 for action_or_command in response_vec {
1105 let action = match action_or_command {
1106 lsp_types::CodeActionOrCommand::CodeAction(action) => convert_code_action(action),
1107 lsp_types::CodeActionOrCommand::Command(cmd) => {
1108 let arguments = cmd.arguments.unwrap_or_else(Vec::new);
1109 CodeAction {
1110 title: cmd.title.clone(),
1111 kind: None,
1112 diagnostics: Vec::new(),
1113 edit: None,
1114 command: Some(CommandDescription {
1115 title: cmd.title,
1116 command: cmd.command,
1117 arguments,
1118 }),
1119 is_preferred: false,
1120 }
1121 }
1122 };
1123 actions.push(action);
1124 }
1125
1126 Ok(CodeActionsResult { actions })
1127 }
1128
1129 pub async fn handle_call_hierarchy_prepare(
1135 &mut self,
1136 file_path: String,
1137 line: u32,
1138 character: u32,
1139 ) -> Result<CallHierarchyPrepareResult> {
1140 if line < 1 || character < 1 {
1142 return Err(Error::InvalidToolParams(
1143 "Line and character positions must be >= 1".to_string(),
1144 ));
1145 }
1146
1147 if line > MAX_POSITION_VALUE || character > MAX_POSITION_VALUE {
1148 return Err(Error::InvalidToolParams(format!(
1149 "Position values must be <= {MAX_POSITION_VALUE}"
1150 )));
1151 }
1152
1153 let path = PathBuf::from(&file_path);
1154 let validated_path = self.validate_path(&path)?;
1155 let client = self.get_client_for_file(&validated_path)?;
1156 let uri = self
1157 .document_tracker
1158 .ensure_open(&validated_path, &client)
1159 .await?;
1160 let lsp_position = mcp_to_lsp_position(line, character);
1161
1162 let params = LspCallHierarchyPrepareParams {
1163 text_document_position_params: TextDocumentPositionParams {
1164 text_document: TextDocumentIdentifier { uri },
1165 position: lsp_position,
1166 },
1167 work_done_progress_params: WorkDoneProgressParams::default(),
1168 };
1169
1170 let timeout_duration = Duration::from_secs(30);
1171 let response: Option<Vec<CallHierarchyItem>> = client
1172 .request(
1173 "textDocument/prepareCallHierarchy",
1174 params,
1175 timeout_duration,
1176 )
1177 .await?;
1178
1179 let lsp_items = response.unwrap_or_default();
1181 let mut items = Vec::with_capacity(lsp_items.len());
1182 for item in lsp_items {
1183 items.push(convert_call_hierarchy_item(item));
1184 }
1185
1186 Ok(CallHierarchyPrepareResult { items })
1187 }
1188
1189 pub async fn handle_incoming_calls(
1195 &mut self,
1196 item: serde_json::Value,
1197 ) -> Result<IncomingCallsResult> {
1198 let lsp_item: CallHierarchyItem = serde_json::from_value(item)
1199 .map_err(|e| Error::InvalidToolParams(format!("Invalid call hierarchy item: {e}")))?;
1200
1201 let path = self.parse_file_uri(&lsp_item.uri)?;
1203 let client = self.get_client_for_file(&path)?;
1204
1205 let params = CallHierarchyIncomingCallsParams {
1206 item: lsp_item,
1207 work_done_progress_params: WorkDoneProgressParams::default(),
1208 partial_result_params: PartialResultParams::default(),
1209 };
1210
1211 let timeout_duration = Duration::from_secs(30);
1212 let response: Option<Vec<CallHierarchyIncomingCall>> = client
1213 .request("callHierarchy/incomingCalls", params, timeout_duration)
1214 .await?;
1215
1216 let lsp_calls = response.unwrap_or_default();
1218 let mut calls = Vec::with_capacity(lsp_calls.len());
1219
1220 for call in lsp_calls {
1221 let from_ranges = {
1222 let mut ranges = Vec::with_capacity(call.from_ranges.len());
1223 for range in call.from_ranges {
1224 ranges.push(normalize_range(range));
1225 }
1226 ranges
1227 };
1228
1229 calls.push(IncomingCall {
1230 from: convert_call_hierarchy_item(call.from),
1231 from_ranges,
1232 });
1233 }
1234
1235 Ok(IncomingCallsResult { calls })
1236 }
1237
1238 pub async fn handle_outgoing_calls(
1244 &mut self,
1245 item: serde_json::Value,
1246 ) -> Result<OutgoingCallsResult> {
1247 let lsp_item: CallHierarchyItem = serde_json::from_value(item)
1248 .map_err(|e| Error::InvalidToolParams(format!("Invalid call hierarchy item: {e}")))?;
1249
1250 let path = self.parse_file_uri(&lsp_item.uri)?;
1252 let client = self.get_client_for_file(&path)?;
1253
1254 let params = CallHierarchyOutgoingCallsParams {
1255 item: lsp_item,
1256 work_done_progress_params: WorkDoneProgressParams::default(),
1257 partial_result_params: PartialResultParams::default(),
1258 };
1259
1260 let timeout_duration = Duration::from_secs(30);
1261 let response: Option<Vec<CallHierarchyOutgoingCall>> = client
1262 .request("callHierarchy/outgoingCalls", params, timeout_duration)
1263 .await?;
1264
1265 let lsp_calls = response.unwrap_or_default();
1267 let mut calls = Vec::with_capacity(lsp_calls.len());
1268
1269 for call in lsp_calls {
1270 let from_ranges = {
1271 let mut ranges = Vec::with_capacity(call.from_ranges.len());
1272 for range in call.from_ranges {
1273 ranges.push(normalize_range(range));
1274 }
1275 ranges
1276 };
1277
1278 calls.push(OutgoingCall {
1279 to: convert_call_hierarchy_item(call.to),
1280 from_ranges,
1281 });
1282 }
1283
1284 Ok(OutgoingCallsResult { calls })
1285 }
1286}
1287
1288fn extract_hover_contents(contents: HoverContents) -> String {
1290 match contents {
1291 HoverContents::Scalar(marked_string) => marked_string_to_string(marked_string),
1292 HoverContents::Array(marked_strings) => marked_strings
1293 .into_iter()
1294 .map(marked_string_to_string)
1295 .collect::<Vec<_>>()
1296 .join("\n\n"),
1297 HoverContents::Markup(markup) => markup.value,
1298 }
1299}
1300
1301fn marked_string_to_string(marked: MarkedString) -> String {
1303 match marked {
1304 MarkedString::String(s) => s,
1305 MarkedString::LanguageString(ls) => format!("```{}\n{}\n```", ls.language, ls.value),
1306 }
1307}
1308
1309const fn normalize_range(range: lsp_types::Range) -> Range {
1311 Range {
1312 start: Position2D {
1313 line: range.start.line + 1,
1314 character: range.start.character + 1,
1315 },
1316 end: Position2D {
1317 line: range.end.line + 1,
1318 character: range.end.character + 1,
1319 },
1320 }
1321}
1322
1323fn convert_document_symbol(symbol: DocumentSymbol) -> Symbol {
1325 Symbol {
1326 name: symbol.name,
1327 kind: format!("{:?}", symbol.kind),
1328 range: normalize_range(symbol.range),
1329 selection_range: normalize_range(symbol.selection_range),
1330 children: symbol
1331 .children
1332 .map(|children| children.into_iter().map(convert_document_symbol).collect()),
1333 }
1334}
1335
1336fn convert_call_hierarchy_item(item: CallHierarchyItem) -> CallHierarchyItemResult {
1338 CallHierarchyItemResult {
1339 name: item.name,
1340 kind: format!("{:?}", item.kind),
1341 detail: item.detail,
1342 uri: item.uri.to_string(),
1343 range: normalize_range(item.range),
1344 selection_range: normalize_range(item.selection_range),
1345 data: item.data,
1346 }
1347}
1348
1349fn convert_code_action(action: lsp_types::CodeAction) -> CodeAction {
1351 let diagnostics = action.diagnostics.map_or_else(Vec::new, |diags| {
1352 let mut result = Vec::with_capacity(diags.len());
1353 for d in diags {
1354 result.push(Diagnostic {
1355 range: normalize_range(d.range),
1356 severity: match d.severity {
1357 Some(lsp_types::DiagnosticSeverity::ERROR) => DiagnosticSeverity::Error,
1358 Some(lsp_types::DiagnosticSeverity::WARNING) => DiagnosticSeverity::Warning,
1359 Some(lsp_types::DiagnosticSeverity::INFORMATION) => {
1360 DiagnosticSeverity::Information
1361 }
1362 Some(lsp_types::DiagnosticSeverity::HINT) => DiagnosticSeverity::Hint,
1363 _ => DiagnosticSeverity::Information,
1364 },
1365 message: d.message,
1366 code: d.code.map(|c| match c {
1367 lsp_types::NumberOrString::Number(n) => n.to_string(),
1368 lsp_types::NumberOrString::String(s) => s,
1369 }),
1370 });
1371 }
1372 result
1373 });
1374
1375 let edit = action.edit.map(|edit| {
1376 let changes = edit.changes.map_or_else(Vec::new, |changes_map| {
1377 let mut result = Vec::with_capacity(changes_map.len());
1378 for (uri, edits) in changes_map {
1379 let mut text_edits = Vec::with_capacity(edits.len());
1380 for e in edits {
1381 text_edits.push(TextEdit {
1382 range: normalize_range(e.range),
1383 new_text: e.new_text,
1384 });
1385 }
1386 result.push(DocumentChanges {
1387 uri: uri.to_string(),
1388 edits: text_edits,
1389 });
1390 }
1391 result
1392 });
1393 WorkspaceEditDescription { changes }
1394 });
1395
1396 let command = action.command.map(|cmd| {
1397 let arguments = cmd.arguments.unwrap_or_else(Vec::new);
1398 CommandDescription {
1399 title: cmd.title,
1400 command: cmd.command,
1401 arguments,
1402 }
1403 });
1404
1405 CodeAction {
1406 title: action.title,
1407 kind: action.kind.map(|k| k.as_str().to_string()),
1408 diagnostics,
1409 edit,
1410 command,
1411 is_preferred: action.is_preferred.unwrap_or(false),
1412 }
1413}
1414
1415#[cfg(test)]
1416#[allow(clippy::unwrap_used)]
1417mod tests {
1418 use std::fs;
1419
1420 use tempfile::TempDir;
1421 use url::Url;
1422
1423 use super::*;
1424
1425 #[test]
1426 fn test_translator_new() {
1427 let translator = Translator::new();
1428 assert_eq!(translator.workspace_roots.len(), 0);
1429 assert_eq!(translator.lsp_clients.len(), 0);
1430 }
1431
1432 #[test]
1433 fn test_set_workspace_roots() {
1434 let mut translator = Translator::new();
1435 let roots = vec![PathBuf::from("/test/root1"), PathBuf::from("/test/root2")];
1436 translator.set_workspace_roots(roots.clone());
1437 assert_eq!(translator.workspace_roots, roots);
1438 }
1439
1440 #[test]
1441 fn test_validate_path_no_workspace_roots() {
1442 let translator = Translator::new();
1443 let temp_dir = TempDir::new().unwrap();
1444 let test_file = temp_dir.path().join("test.rs");
1445 fs::write(&test_file, "fn main() {}").unwrap();
1446
1447 let result = translator.validate_path(&test_file);
1449 assert!(result.is_ok());
1450 }
1451
1452 #[test]
1453 fn test_validate_path_within_workspace() {
1454 let mut translator = Translator::new();
1455 let temp_dir = TempDir::new().unwrap();
1456 let workspace_root = temp_dir.path().to_path_buf();
1457 translator.set_workspace_roots(vec![workspace_root]);
1458
1459 let test_file = temp_dir.path().join("test.rs");
1460 fs::write(&test_file, "fn main() {}").unwrap();
1461
1462 let result = translator.validate_path(&test_file);
1463 assert!(result.is_ok());
1464 }
1465
1466 #[test]
1467 fn test_validate_path_outside_workspace() {
1468 let mut translator = Translator::new();
1469 let temp_dir1 = TempDir::new().unwrap();
1470 let temp_dir2 = TempDir::new().unwrap();
1471
1472 translator.set_workspace_roots(vec![temp_dir1.path().to_path_buf()]);
1474
1475 let test_file = temp_dir2.path().join("test.rs");
1477 fs::write(&test_file, "fn main() {}").unwrap();
1478
1479 let result = translator.validate_path(&test_file);
1480 assert!(matches!(result, Err(Error::PathOutsideWorkspace(_))));
1481 }
1482
1483 #[test]
1484 fn test_normalize_range() {
1485 let lsp_range = lsp_types::Range {
1486 start: lsp_types::Position {
1487 line: 0,
1488 character: 0,
1489 },
1490 end: lsp_types::Position {
1491 line: 2,
1492 character: 5,
1493 },
1494 };
1495
1496 let mcp_range = normalize_range(lsp_range);
1497 assert_eq!(mcp_range.start.line, 1);
1498 assert_eq!(mcp_range.start.character, 1);
1499 assert_eq!(mcp_range.end.line, 3);
1500 assert_eq!(mcp_range.end.character, 6);
1501 }
1502
1503 #[test]
1504 fn test_extract_hover_contents_string() {
1505 let marked_string = lsp_types::MarkedString::String("Test hover".to_string());
1506 let contents = lsp_types::HoverContents::Scalar(marked_string);
1507 let result = extract_hover_contents(contents);
1508 assert_eq!(result, "Test hover");
1509 }
1510
1511 #[test]
1512 fn test_extract_hover_contents_language_string() {
1513 let marked_string = lsp_types::MarkedString::LanguageString(lsp_types::LanguageString {
1514 language: "rust".to_string(),
1515 value: "fn main() {}".to_string(),
1516 });
1517 let contents = lsp_types::HoverContents::Scalar(marked_string);
1518 let result = extract_hover_contents(contents);
1519 assert_eq!(result, "```rust\nfn main() {}\n```");
1520 }
1521
1522 #[test]
1523 fn test_extract_hover_contents_markup() {
1524 let markup = lsp_types::MarkupContent {
1525 kind: lsp_types::MarkupKind::Markdown,
1526 value: "# Documentation".to_string(),
1527 };
1528 let contents = lsp_types::HoverContents::Markup(markup);
1529 let result = extract_hover_contents(contents);
1530 assert_eq!(result, "# Documentation");
1531 }
1532
1533 #[tokio::test]
1534 async fn test_handle_workspace_symbol_no_server() {
1535 let mut translator = Translator::new();
1536 let result = translator
1537 .handle_workspace_symbol("test".to_string(), None, 100)
1538 .await;
1539 assert!(matches!(result, Err(Error::NoServerConfigured)));
1540 }
1541
1542 #[tokio::test]
1543 async fn test_handle_code_actions_invalid_kind() {
1544 let mut translator = Translator::new();
1545 let result = translator
1546 .handle_code_actions(
1547 "/tmp/test.rs".to_string(),
1548 1,
1549 1,
1550 1,
1551 10,
1552 Some("invalid_kind".to_string()),
1553 )
1554 .await;
1555 assert!(matches!(result, Err(Error::InvalidToolParams(_))));
1556 }
1557
1558 #[tokio::test]
1559 async fn test_handle_code_actions_valid_kind_quickfix() {
1560 use tempfile::TempDir;
1561
1562 let mut translator = Translator::new();
1563 let temp_dir = TempDir::new().unwrap();
1564 let test_file = temp_dir.path().join("test.rs");
1565 fs::write(&test_file, "fn main() {}").unwrap();
1566
1567 let result = translator
1568 .handle_code_actions(
1569 test_file.to_str().unwrap().to_string(),
1570 1,
1571 1,
1572 1,
1573 10,
1574 Some("quickfix".to_string()),
1575 )
1576 .await;
1577 assert!(result.is_err());
1579 assert!(!matches!(result, Err(Error::InvalidToolParams(_))));
1580 }
1581
1582 #[tokio::test]
1583 async fn test_handle_code_actions_valid_kind_refactor() {
1584 use tempfile::TempDir;
1585
1586 let mut translator = Translator::new();
1587 let temp_dir = TempDir::new().unwrap();
1588 let test_file = temp_dir.path().join("test.rs");
1589 fs::write(&test_file, "fn main() {}").unwrap();
1590
1591 let result = translator
1592 .handle_code_actions(
1593 test_file.to_str().unwrap().to_string(),
1594 1,
1595 1,
1596 1,
1597 10,
1598 Some("refactor".to_string()),
1599 )
1600 .await;
1601 assert!(result.is_err());
1602 assert!(!matches!(result, Err(Error::InvalidToolParams(_))));
1603 }
1604
1605 #[tokio::test]
1606 async fn test_handle_code_actions_valid_kind_refactor_extract() {
1607 use tempfile::TempDir;
1608
1609 let mut translator = Translator::new();
1610 let temp_dir = TempDir::new().unwrap();
1611 let test_file = temp_dir.path().join("test.rs");
1612 fs::write(&test_file, "fn main() {}").unwrap();
1613
1614 let result = translator
1615 .handle_code_actions(
1616 test_file.to_str().unwrap().to_string(),
1617 1,
1618 1,
1619 1,
1620 10,
1621 Some("refactor.extract".to_string()),
1622 )
1623 .await;
1624 assert!(result.is_err());
1625 assert!(!matches!(result, Err(Error::InvalidToolParams(_))));
1626 }
1627
1628 #[tokio::test]
1629 async fn test_handle_code_actions_valid_kind_source() {
1630 use tempfile::TempDir;
1631
1632 let mut translator = Translator::new();
1633 let temp_dir = TempDir::new().unwrap();
1634 let test_file = temp_dir.path().join("test.rs");
1635 fs::write(&test_file, "fn main() {}").unwrap();
1636
1637 let result = translator
1638 .handle_code_actions(
1639 test_file.to_str().unwrap().to_string(),
1640 1,
1641 1,
1642 1,
1643 10,
1644 Some("source.organizeImports".to_string()),
1645 )
1646 .await;
1647 assert!(result.is_err());
1648 assert!(!matches!(result, Err(Error::InvalidToolParams(_))));
1649 }
1650
1651 #[tokio::test]
1652 async fn test_handle_code_actions_invalid_range_zero() {
1653 let mut translator = Translator::new();
1654 let result = translator
1655 .handle_code_actions("/tmp/test.rs".to_string(), 0, 1, 1, 10, None)
1656 .await;
1657 assert!(matches!(result, Err(Error::InvalidToolParams(_))));
1658 }
1659
1660 #[tokio::test]
1661 async fn test_handle_code_actions_invalid_range_order() {
1662 let mut translator = Translator::new();
1663 let result = translator
1664 .handle_code_actions("/tmp/test.rs".to_string(), 10, 5, 5, 1, None)
1665 .await;
1666 assert!(matches!(result, Err(Error::InvalidToolParams(_))));
1667 }
1668
1669 #[tokio::test]
1670 async fn test_handle_code_actions_empty_range() {
1671 use tempfile::TempDir;
1672
1673 let mut translator = Translator::new();
1674 let temp_dir = TempDir::new().unwrap();
1675 let test_file = temp_dir.path().join("test.rs");
1676 fs::write(&test_file, "fn main() {}").unwrap();
1677
1678 let result = translator
1680 .handle_code_actions(test_file.to_str().unwrap().to_string(), 1, 5, 1, 5, None)
1681 .await;
1682 assert!(result.is_err());
1684 assert!(!matches!(result, Err(Error::InvalidToolParams(_))));
1685 }
1686
1687 #[test]
1688 fn test_convert_code_action_minimal() {
1689 let lsp_action = lsp_types::CodeAction {
1690 title: "Fix issue".to_string(),
1691 kind: None,
1692 diagnostics: None,
1693 edit: None,
1694 command: None,
1695 is_preferred: None,
1696 disabled: None,
1697 data: None,
1698 };
1699
1700 let result = convert_code_action(lsp_action);
1701 assert_eq!(result.title, "Fix issue");
1702 assert!(result.kind.is_none());
1703 assert!(result.diagnostics.is_empty());
1704 assert!(result.edit.is_none());
1705 assert!(result.command.is_none());
1706 assert!(!result.is_preferred);
1707 }
1708
1709 #[test]
1710 #[allow(clippy::too_many_lines)]
1711 fn test_convert_code_action_with_diagnostics_all_severities() {
1712 let lsp_diagnostics = vec![
1713 lsp_types::Diagnostic {
1714 range: lsp_types::Range {
1715 start: lsp_types::Position {
1716 line: 0,
1717 character: 0,
1718 },
1719 end: lsp_types::Position {
1720 line: 0,
1721 character: 5,
1722 },
1723 },
1724 severity: Some(lsp_types::DiagnosticSeverity::ERROR),
1725 message: "Error message".to_string(),
1726 code: Some(lsp_types::NumberOrString::Number(1)),
1727 source: None,
1728 code_description: None,
1729 related_information: None,
1730 tags: None,
1731 data: None,
1732 },
1733 lsp_types::Diagnostic {
1734 range: lsp_types::Range {
1735 start: lsp_types::Position {
1736 line: 1,
1737 character: 0,
1738 },
1739 end: lsp_types::Position {
1740 line: 1,
1741 character: 5,
1742 },
1743 },
1744 severity: Some(lsp_types::DiagnosticSeverity::WARNING),
1745 message: "Warning message".to_string(),
1746 code: Some(lsp_types::NumberOrString::String("W001".to_string())),
1747 source: None,
1748 code_description: None,
1749 related_information: None,
1750 tags: None,
1751 data: None,
1752 },
1753 lsp_types::Diagnostic {
1754 range: lsp_types::Range {
1755 start: lsp_types::Position {
1756 line: 2,
1757 character: 0,
1758 },
1759 end: lsp_types::Position {
1760 line: 2,
1761 character: 5,
1762 },
1763 },
1764 severity: Some(lsp_types::DiagnosticSeverity::INFORMATION),
1765 message: "Info message".to_string(),
1766 code: None,
1767 source: None,
1768 code_description: None,
1769 related_information: None,
1770 tags: None,
1771 data: None,
1772 },
1773 lsp_types::Diagnostic {
1774 range: lsp_types::Range {
1775 start: lsp_types::Position {
1776 line: 3,
1777 character: 0,
1778 },
1779 end: lsp_types::Position {
1780 line: 3,
1781 character: 5,
1782 },
1783 },
1784 severity: Some(lsp_types::DiagnosticSeverity::HINT),
1785 message: "Hint message".to_string(),
1786 code: None,
1787 source: None,
1788 code_description: None,
1789 related_information: None,
1790 tags: None,
1791 data: None,
1792 },
1793 ];
1794
1795 let lsp_action = lsp_types::CodeAction {
1796 title: "Fix all issues".to_string(),
1797 kind: Some(lsp_types::CodeActionKind::QUICKFIX),
1798 diagnostics: Some(lsp_diagnostics),
1799 edit: None,
1800 command: None,
1801 is_preferred: None,
1802 disabled: None,
1803 data: None,
1804 };
1805
1806 let result = convert_code_action(lsp_action);
1807 assert_eq!(result.diagnostics.len(), 4);
1808 assert!(matches!(
1809 result.diagnostics[0].severity,
1810 DiagnosticSeverity::Error
1811 ));
1812 assert!(matches!(
1813 result.diagnostics[1].severity,
1814 DiagnosticSeverity::Warning
1815 ));
1816 assert!(matches!(
1817 result.diagnostics[2].severity,
1818 DiagnosticSeverity::Information
1819 ));
1820 assert!(matches!(
1821 result.diagnostics[3].severity,
1822 DiagnosticSeverity::Hint
1823 ));
1824 assert_eq!(result.diagnostics[0].code, Some("1".to_string()));
1825 assert_eq!(result.diagnostics[1].code, Some("W001".to_string()));
1826 }
1827
1828 #[test]
1829 #[allow(clippy::mutable_key_type)]
1830 fn test_convert_code_action_with_workspace_edit() {
1831 use std::collections::HashMap;
1832 use std::str::FromStr;
1833
1834 let uri = lsp_types::Uri::from_str("file:///test.rs").unwrap();
1835 let mut changes_map = HashMap::new();
1836 changes_map.insert(
1837 uri,
1838 vec![lsp_types::TextEdit {
1839 range: lsp_types::Range {
1840 start: lsp_types::Position {
1841 line: 0,
1842 character: 0,
1843 },
1844 end: lsp_types::Position {
1845 line: 0,
1846 character: 5,
1847 },
1848 },
1849 new_text: "fixed".to_string(),
1850 }],
1851 );
1852
1853 let lsp_action = lsp_types::CodeAction {
1854 title: "Apply fix".to_string(),
1855 kind: Some(lsp_types::CodeActionKind::QUICKFIX),
1856 diagnostics: None,
1857 edit: Some(lsp_types::WorkspaceEdit {
1858 changes: Some(changes_map),
1859 document_changes: None,
1860 change_annotations: None,
1861 }),
1862 command: None,
1863 is_preferred: Some(true),
1864 disabled: None,
1865 data: None,
1866 };
1867
1868 let result = convert_code_action(lsp_action);
1869 assert!(result.edit.is_some());
1870 let edit = result.edit.unwrap();
1871 assert_eq!(edit.changes.len(), 1);
1872 assert_eq!(edit.changes[0].uri, "file:///test.rs");
1873 assert_eq!(edit.changes[0].edits.len(), 1);
1874 assert_eq!(edit.changes[0].edits[0].new_text, "fixed");
1875 assert!(result.is_preferred);
1876 }
1877
1878 #[test]
1879 fn test_convert_code_action_with_command() {
1880 let lsp_action = lsp_types::CodeAction {
1881 title: "Run command".to_string(),
1882 kind: Some(lsp_types::CodeActionKind::REFACTOR),
1883 diagnostics: None,
1884 edit: None,
1885 command: Some(lsp_types::Command {
1886 title: "Execute refactor".to_string(),
1887 command: "refactor.extract".to_string(),
1888 arguments: Some(vec![serde_json::json!("arg1"), serde_json::json!(42)]),
1889 }),
1890 is_preferred: None,
1891 disabled: None,
1892 data: None,
1893 };
1894
1895 let result = convert_code_action(lsp_action);
1896 assert!(result.command.is_some());
1897 let cmd = result.command.unwrap();
1898 assert_eq!(cmd.title, "Execute refactor");
1899 assert_eq!(cmd.command, "refactor.extract");
1900 assert_eq!(cmd.arguments.len(), 2);
1901 }
1902
1903 #[tokio::test]
1904 async fn test_handle_call_hierarchy_prepare_invalid_position_zero() {
1905 let mut translator = Translator::new();
1906 let result = translator
1907 .handle_call_hierarchy_prepare("/tmp/test.rs".to_string(), 0, 1)
1908 .await;
1909 assert!(matches!(result, Err(Error::InvalidToolParams(_))));
1910
1911 let result = translator
1912 .handle_call_hierarchy_prepare("/tmp/test.rs".to_string(), 1, 0)
1913 .await;
1914 assert!(matches!(result, Err(Error::InvalidToolParams(_))));
1915 }
1916
1917 #[tokio::test]
1918 async fn test_handle_call_hierarchy_prepare_invalid_position_too_large() {
1919 let mut translator = Translator::new();
1920 let result = translator
1921 .handle_call_hierarchy_prepare("/tmp/test.rs".to_string(), 1_000_001, 1)
1922 .await;
1923 assert!(matches!(result, Err(Error::InvalidToolParams(_))));
1924
1925 let result = translator
1926 .handle_call_hierarchy_prepare("/tmp/test.rs".to_string(), 1, 1_000_001)
1927 .await;
1928 assert!(matches!(result, Err(Error::InvalidToolParams(_))));
1929 }
1930
1931 #[tokio::test]
1932 async fn test_handle_incoming_calls_invalid_json() {
1933 let mut translator = Translator::new();
1934 let invalid_item = serde_json::json!({"invalid": "structure"});
1935 let result = translator.handle_incoming_calls(invalid_item).await;
1936 assert!(matches!(result, Err(Error::InvalidToolParams(_))));
1937 }
1938
1939 #[tokio::test]
1940 async fn test_handle_outgoing_calls_invalid_json() {
1941 let mut translator = Translator::new();
1942 let invalid_item = serde_json::json!({"invalid": "structure"});
1943 let result = translator.handle_outgoing_calls(invalid_item).await;
1944 assert!(matches!(result, Err(Error::InvalidToolParams(_))));
1945 }
1946
1947 #[tokio::test]
1948 async fn test_parse_file_uri_invalid_scheme() {
1949 let translator = Translator::new();
1950 let uri: lsp_types::Uri = "http://example.com/file.rs".parse().unwrap();
1951 let result = translator.parse_file_uri(&uri);
1952 assert!(matches!(result, Err(Error::InvalidToolParams(_))));
1953 }
1954
1955 #[tokio::test]
1956 async fn test_parse_file_uri_valid_scheme() {
1957 let translator = Translator::new();
1958 let temp_dir = TempDir::new().unwrap();
1959 let test_file = temp_dir.path().join("test.rs");
1960 fs::write(&test_file, "fn main() {}").unwrap();
1961
1962 let file_url = Url::from_file_path(&test_file).unwrap();
1964 let uri: lsp_types::Uri = file_url.as_str().parse().unwrap();
1965 let result = translator.parse_file_uri(&uri);
1966 assert!(result.is_ok());
1967 }
1968}