1use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5
6use lsp_types::{
7 CompletionParams, CompletionTriggerKind, DocumentFormattingParams, DocumentSymbol,
8 DocumentSymbolParams, FormattingOptions, GotoDefinitionParams, Hover, HoverContents,
9 HoverParams as LspHoverParams, MarkedString, PartialResultParams, ReferenceContext,
10 ReferenceParams, RenameParams as LspRenameParams, TextDocumentIdentifier,
11 TextDocumentPositionParams, WorkDoneProgressParams, WorkspaceEdit,
12};
13use serde::{Deserialize, Serialize};
14use tokio::time::Duration;
15
16use super::DocumentTracker;
17use super::state::detect_language;
18use crate::bridge::encoding::mcp_to_lsp_position;
19use crate::error::{Error, Result};
20use crate::lsp::LspClient;
21
22#[derive(Debug)]
24pub struct Translator {
25 lsp_clients: HashMap<String, LspClient>,
27 document_tracker: DocumentTracker,
29 workspace_roots: Vec<PathBuf>,
31}
32
33impl Translator {
34 #[must_use]
36 pub fn new() -> Self {
37 Self {
38 lsp_clients: HashMap::new(),
39 document_tracker: DocumentTracker::new(),
40 workspace_roots: vec![],
41 }
42 }
43
44 pub fn set_workspace_roots(&mut self, roots: Vec<PathBuf>) {
46 self.workspace_roots = roots;
47 }
48
49 pub fn register_client(&mut self, language_id: String, client: LspClient) {
51 self.lsp_clients.insert(language_id, client);
52 }
53
54 #[must_use]
56 pub const fn document_tracker(&self) -> &DocumentTracker {
57 &self.document_tracker
58 }
59
60 pub const fn document_tracker_mut(&mut self) -> &mut DocumentTracker {
62 &mut self.document_tracker
63 }
64
65 }
70
71impl Default for Translator {
72 fn default() -> Self {
73 Self::new()
74 }
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct Position2D {
80 pub line: u32,
82 pub character: u32,
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct Range {
89 pub start: Position2D,
91 pub end: Position2D,
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct Location {
98 pub uri: String,
100 pub range: Range,
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct HoverResult {
107 pub contents: String,
109 pub range: Option<Range>,
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct DefinitionResult {
116 pub locations: Vec<Location>,
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct ReferencesResult {
123 pub locations: Vec<Location>,
125}
126
127#[derive(Debug, Clone, Serialize, Deserialize)]
129#[serde(rename_all = "lowercase")]
130pub enum DiagnosticSeverity {
131 Error,
133 Warning,
135 Information,
137 Hint,
139}
140
141#[derive(Debug, Clone, Serialize, Deserialize)]
143pub struct Diagnostic {
144 pub range: Range,
146 pub severity: DiagnosticSeverity,
148 pub message: String,
150 pub code: Option<String>,
152}
153
154#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct DiagnosticsResult {
157 pub diagnostics: Vec<Diagnostic>,
159}
160
161#[derive(Debug, Clone, Serialize, Deserialize)]
163pub struct TextEdit {
164 pub range: Range,
166 pub new_text: String,
168}
169
170#[derive(Debug, Clone, Serialize, Deserialize)]
172pub struct DocumentChanges {
173 pub uri: String,
175 pub edits: Vec<TextEdit>,
177}
178
179#[derive(Debug, Clone, Serialize, Deserialize)]
181pub struct RenameResult {
182 pub changes: Vec<DocumentChanges>,
184}
185
186#[derive(Debug, Clone, Serialize, Deserialize)]
188pub struct Completion {
189 pub label: String,
191 pub kind: Option<String>,
193 pub detail: Option<String>,
195 pub documentation: Option<String>,
197}
198
199#[derive(Debug, Clone, Serialize, Deserialize)]
201pub struct CompletionsResult {
202 pub items: Vec<Completion>,
204}
205
206#[derive(Debug, Clone, Serialize, Deserialize)]
208pub struct Symbol {
209 pub name: String,
211 pub kind: String,
213 pub range: Range,
215 pub selection_range: Range,
217 #[serde(skip_serializing_if = "Option::is_none")]
219 pub children: Option<Vec<Self>>,
220}
221
222#[derive(Debug, Clone, Serialize, Deserialize)]
224pub struct DocumentSymbolsResult {
225 pub symbols: Vec<Symbol>,
227}
228
229#[derive(Debug, Clone, Serialize, Deserialize)]
231pub struct FormatDocumentResult {
232 pub edits: Vec<TextEdit>,
234}
235
236impl Translator {
237 fn validate_path(&self, path: &Path) -> Result<PathBuf> {
243 let canonical = path.canonicalize().map_err(|e| Error::FileIo {
244 path: path.to_path_buf(),
245 source: e,
246 })?;
247
248 if self.workspace_roots.is_empty() {
250 return Ok(canonical);
251 }
252
253 for root in &self.workspace_roots {
255 if let Ok(canonical_root) = root.canonicalize() {
256 if canonical.starts_with(&canonical_root) {
257 return Ok(canonical);
258 }
259 }
260 }
261
262 Err(Error::PathOutsideWorkspace(path.to_path_buf()))
263 }
264
265 fn get_client_for_file(&self, path: &Path) -> Result<LspClient> {
267 let language_id = detect_language(path);
268 self.lsp_clients
269 .get(&language_id)
270 .cloned()
271 .ok_or(Error::NoServerForLanguage(language_id))
272 }
273
274 pub async fn handle_hover(
280 &mut self,
281 file_path: String,
282 line: u32,
283 character: u32,
284 ) -> Result<HoverResult> {
285 let path = PathBuf::from(&file_path);
286 let validated_path = self.validate_path(&path)?;
287 let client = self.get_client_for_file(&validated_path)?;
288 let uri = self
289 .document_tracker
290 .ensure_open(&validated_path, &client)
291 .await?;
292 let lsp_position = mcp_to_lsp_position(line, character);
293
294 let params = LspHoverParams {
295 text_document_position_params: TextDocumentPositionParams {
296 text_document: TextDocumentIdentifier { uri },
297 position: lsp_position,
298 },
299 work_done_progress_params: WorkDoneProgressParams::default(),
300 };
301
302 let timeout_duration = Duration::from_secs(30);
303 let response: Option<Hover> = client
304 .request("textDocument/hover", params, timeout_duration)
305 .await?;
306
307 let result = match response {
308 Some(hover) => {
309 let contents = extract_hover_contents(hover.contents);
310 let range = hover.range.map(normalize_range);
311 HoverResult { contents, range }
312 }
313 None => HoverResult {
314 contents: "No hover information available".to_string(),
315 range: None,
316 },
317 };
318
319 Ok(result)
320 }
321
322 pub async fn handle_definition(
328 &mut self,
329 file_path: String,
330 line: u32,
331 character: u32,
332 ) -> Result<DefinitionResult> {
333 let path = PathBuf::from(&file_path);
334 let validated_path = self.validate_path(&path)?;
335 let client = self.get_client_for_file(&validated_path)?;
336 let uri = self
337 .document_tracker
338 .ensure_open(&validated_path, &client)
339 .await?;
340 let lsp_position = mcp_to_lsp_position(line, character);
341
342 let params = GotoDefinitionParams {
343 text_document_position_params: TextDocumentPositionParams {
344 text_document: TextDocumentIdentifier { uri },
345 position: lsp_position,
346 },
347 work_done_progress_params: WorkDoneProgressParams::default(),
348 partial_result_params: PartialResultParams::default(),
349 };
350
351 let timeout_duration = Duration::from_secs(30);
352 let response: Option<lsp_types::GotoDefinitionResponse> = client
353 .request("textDocument/definition", params, timeout_duration)
354 .await?;
355
356 let locations = match response {
357 Some(lsp_types::GotoDefinitionResponse::Scalar(loc)) => vec![loc],
358 Some(lsp_types::GotoDefinitionResponse::Array(locs)) => locs,
359 Some(lsp_types::GotoDefinitionResponse::Link(links)) => links
360 .into_iter()
361 .map(|link| lsp_types::Location {
362 uri: link.target_uri,
363 range: link.target_selection_range,
364 })
365 .collect(),
366 None => vec![],
367 };
368
369 let result = DefinitionResult {
370 locations: locations
371 .into_iter()
372 .map(|loc| Location {
373 uri: loc.uri.to_string(),
374 range: normalize_range(loc.range),
375 })
376 .collect(),
377 };
378
379 Ok(result)
380 }
381
382 pub async fn handle_references(
388 &mut self,
389 file_path: String,
390 line: u32,
391 character: u32,
392 include_declaration: bool,
393 ) -> Result<ReferencesResult> {
394 let path = PathBuf::from(&file_path);
395 let validated_path = self.validate_path(&path)?;
396 let client = self.get_client_for_file(&validated_path)?;
397 let uri = self
398 .document_tracker
399 .ensure_open(&validated_path, &client)
400 .await?;
401 let lsp_position = mcp_to_lsp_position(line, character);
402
403 let params = ReferenceParams {
404 text_document_position: TextDocumentPositionParams {
405 text_document: TextDocumentIdentifier { uri },
406 position: lsp_position,
407 },
408 work_done_progress_params: WorkDoneProgressParams::default(),
409 partial_result_params: PartialResultParams::default(),
410 context: ReferenceContext {
411 include_declaration,
412 },
413 };
414
415 let timeout_duration = Duration::from_secs(30);
416 let response: Option<Vec<lsp_types::Location>> = client
417 .request("textDocument/references", params, timeout_duration)
418 .await?;
419
420 let locations = response.unwrap_or_default();
421
422 let result = ReferencesResult {
423 locations: locations
424 .into_iter()
425 .map(|loc| Location {
426 uri: loc.uri.to_string(),
427 range: normalize_range(loc.range),
428 })
429 .collect(),
430 };
431
432 Ok(result)
433 }
434
435 pub async fn handle_diagnostics(&mut self, file_path: String) -> Result<DiagnosticsResult> {
441 let path = PathBuf::from(&file_path);
442 let validated_path = self.validate_path(&path)?;
443 let client = self.get_client_for_file(&validated_path)?;
444 let uri = self
445 .document_tracker
446 .ensure_open(&validated_path, &client)
447 .await?;
448
449 let params = lsp_types::DocumentDiagnosticParams {
450 text_document: TextDocumentIdentifier { uri },
451 identifier: None,
452 previous_result_id: None,
453 work_done_progress_params: WorkDoneProgressParams::default(),
454 partial_result_params: PartialResultParams::default(),
455 };
456
457 let timeout_duration = Duration::from_secs(30);
458 let response: lsp_types::DocumentDiagnosticReportResult = client
459 .request("textDocument/diagnostic", params, timeout_duration)
460 .await?;
461
462 let diagnostics = match response {
463 lsp_types::DocumentDiagnosticReportResult::Report(report) => match report {
464 lsp_types::DocumentDiagnosticReport::Full(full) => {
465 full.full_document_diagnostic_report.items
466 }
467 lsp_types::DocumentDiagnosticReport::Unchanged(_) => vec![],
468 },
469 lsp_types::DocumentDiagnosticReportResult::Partial(_) => vec![],
470 };
471
472 let result = DiagnosticsResult {
473 diagnostics: diagnostics
474 .into_iter()
475 .map(|diag| Diagnostic {
476 range: normalize_range(diag.range),
477 severity: match diag.severity {
478 Some(lsp_types::DiagnosticSeverity::ERROR) => DiagnosticSeverity::Error,
479 Some(lsp_types::DiagnosticSeverity::WARNING) => DiagnosticSeverity::Warning,
480 Some(lsp_types::DiagnosticSeverity::INFORMATION) => {
481 DiagnosticSeverity::Information
482 }
483 Some(lsp_types::DiagnosticSeverity::HINT) => DiagnosticSeverity::Hint,
484 _ => DiagnosticSeverity::Information,
485 },
486 message: diag.message,
487 code: diag.code.map(|c| match c {
488 lsp_types::NumberOrString::Number(n) => n.to_string(),
489 lsp_types::NumberOrString::String(s) => s,
490 }),
491 })
492 .collect(),
493 };
494
495 Ok(result)
496 }
497
498 pub async fn handle_rename(
504 &mut self,
505 file_path: String,
506 line: u32,
507 character: u32,
508 new_name: String,
509 ) -> Result<RenameResult> {
510 let path = PathBuf::from(&file_path);
511 let validated_path = self.validate_path(&path)?;
512 let client = self.get_client_for_file(&validated_path)?;
513 let uri = self
514 .document_tracker
515 .ensure_open(&validated_path, &client)
516 .await?;
517 let lsp_position = mcp_to_lsp_position(line, character);
518
519 let params = LspRenameParams {
520 text_document_position: TextDocumentPositionParams {
521 text_document: TextDocumentIdentifier { uri },
522 position: lsp_position,
523 },
524 new_name,
525 work_done_progress_params: WorkDoneProgressParams::default(),
526 };
527
528 let timeout_duration = Duration::from_secs(30);
529 let response: Option<WorkspaceEdit> = client
530 .request("textDocument/rename", params, timeout_duration)
531 .await?;
532
533 let changes = if let Some(edit) = response {
534 let mut result_changes = Vec::new();
535
536 if let Some(changes_map) = edit.changes {
537 for (uri, edits) in changes_map {
538 result_changes.push(DocumentChanges {
539 uri: uri.to_string(),
540 edits: edits
541 .into_iter()
542 .map(|edit| TextEdit {
543 range: normalize_range(edit.range),
544 new_text: edit.new_text,
545 })
546 .collect(),
547 });
548 }
549 }
550
551 result_changes
552 } else {
553 vec![]
554 };
555
556 Ok(RenameResult { changes })
557 }
558
559 pub async fn handle_completions(
565 &mut self,
566 file_path: String,
567 line: u32,
568 character: u32,
569 trigger: Option<String>,
570 ) -> Result<CompletionsResult> {
571 let path = PathBuf::from(&file_path);
572 let validated_path = self.validate_path(&path)?;
573 let client = self.get_client_for_file(&validated_path)?;
574 let uri = self
575 .document_tracker
576 .ensure_open(&validated_path, &client)
577 .await?;
578 let lsp_position = mcp_to_lsp_position(line, character);
579
580 let context = trigger.map(|trigger_char| lsp_types::CompletionContext {
581 trigger_kind: CompletionTriggerKind::TRIGGER_CHARACTER,
582 trigger_character: Some(trigger_char),
583 });
584
585 let params = CompletionParams {
586 text_document_position: TextDocumentPositionParams {
587 text_document: TextDocumentIdentifier { uri },
588 position: lsp_position,
589 },
590 work_done_progress_params: WorkDoneProgressParams::default(),
591 partial_result_params: PartialResultParams::default(),
592 context,
593 };
594
595 let timeout_duration = Duration::from_secs(10);
596 let response: Option<lsp_types::CompletionResponse> = client
597 .request("textDocument/completion", params, timeout_duration)
598 .await?;
599
600 let items = match response {
601 Some(lsp_types::CompletionResponse::Array(items)) => items,
602 Some(lsp_types::CompletionResponse::List(list)) => list.items,
603 None => vec![],
604 };
605
606 let result = CompletionsResult {
607 items: items
608 .into_iter()
609 .map(|item| Completion {
610 label: item.label,
611 kind: item.kind.map(|k| format!("{k:?}")),
612 detail: item.detail,
613 documentation: item.documentation.map(|doc| match doc {
614 lsp_types::Documentation::String(s) => s,
615 lsp_types::Documentation::MarkupContent(m) => m.value,
616 }),
617 })
618 .collect(),
619 };
620
621 Ok(result)
622 }
623
624 pub async fn handle_document_symbols(
630 &mut self,
631 file_path: String,
632 ) -> Result<DocumentSymbolsResult> {
633 let path = PathBuf::from(&file_path);
634 let validated_path = self.validate_path(&path)?;
635 let client = self.get_client_for_file(&validated_path)?;
636 let uri = self
637 .document_tracker
638 .ensure_open(&validated_path, &client)
639 .await?;
640
641 let params = DocumentSymbolParams {
642 text_document: TextDocumentIdentifier { uri },
643 work_done_progress_params: WorkDoneProgressParams::default(),
644 partial_result_params: PartialResultParams::default(),
645 };
646
647 let timeout_duration = Duration::from_secs(30);
648 let response: Option<lsp_types::DocumentSymbolResponse> = client
649 .request("textDocument/documentSymbol", params, timeout_duration)
650 .await?;
651
652 let symbols = match response {
653 Some(lsp_types::DocumentSymbolResponse::Flat(symbols)) => symbols
654 .into_iter()
655 .map(|sym| Symbol {
656 name: sym.name,
657 kind: format!("{:?}", sym.kind),
658 range: normalize_range(sym.location.range),
659 selection_range: normalize_range(sym.location.range),
660 children: None,
661 })
662 .collect(),
663 Some(lsp_types::DocumentSymbolResponse::Nested(symbols)) => {
664 symbols.into_iter().map(convert_document_symbol).collect()
665 }
666 None => vec![],
667 };
668
669 Ok(DocumentSymbolsResult { symbols })
670 }
671
672 pub async fn handle_format_document(
678 &mut self,
679 file_path: String,
680 tab_size: u32,
681 insert_spaces: bool,
682 ) -> Result<FormatDocumentResult> {
683 let path = PathBuf::from(&file_path);
684 let validated_path = self.validate_path(&path)?;
685 let client = self.get_client_for_file(&validated_path)?;
686 let uri = self
687 .document_tracker
688 .ensure_open(&validated_path, &client)
689 .await?;
690
691 let params = DocumentFormattingParams {
692 text_document: TextDocumentIdentifier { uri },
693 options: FormattingOptions {
694 tab_size,
695 insert_spaces,
696 ..Default::default()
697 },
698 work_done_progress_params: WorkDoneProgressParams::default(),
699 };
700
701 let timeout_duration = Duration::from_secs(30);
702 let response: Option<Vec<lsp_types::TextEdit>> = client
703 .request("textDocument/formatting", params, timeout_duration)
704 .await?;
705
706 let edits = response.unwrap_or_default();
707
708 let result = FormatDocumentResult {
709 edits: edits
710 .into_iter()
711 .map(|edit| TextEdit {
712 range: normalize_range(edit.range),
713 new_text: edit.new_text,
714 })
715 .collect(),
716 };
717
718 Ok(result)
719 }
720}
721
722fn extract_hover_contents(contents: HoverContents) -> String {
724 match contents {
725 HoverContents::Scalar(marked_string) => marked_string_to_string(marked_string),
726 HoverContents::Array(marked_strings) => marked_strings
727 .into_iter()
728 .map(marked_string_to_string)
729 .collect::<Vec<_>>()
730 .join("\n\n"),
731 HoverContents::Markup(markup) => markup.value,
732 }
733}
734
735fn marked_string_to_string(marked: MarkedString) -> String {
737 match marked {
738 MarkedString::String(s) => s,
739 MarkedString::LanguageString(ls) => format!("```{}\n{}\n```", ls.language, ls.value),
740 }
741}
742
743const fn normalize_range(range: lsp_types::Range) -> Range {
745 Range {
746 start: Position2D {
747 line: range.start.line + 1,
748 character: range.start.character + 1,
749 },
750 end: Position2D {
751 line: range.end.line + 1,
752 character: range.end.character + 1,
753 },
754 }
755}
756
757fn convert_document_symbol(symbol: DocumentSymbol) -> Symbol {
759 Symbol {
760 name: symbol.name,
761 kind: format!("{:?}", symbol.kind),
762 range: normalize_range(symbol.range),
763 selection_range: normalize_range(symbol.selection_range),
764 children: symbol
765 .children
766 .map(|children| children.into_iter().map(convert_document_symbol).collect()),
767 }
768}
769
770#[cfg(test)]
771#[allow(clippy::unwrap_used)]
772mod tests {
773 use std::fs;
774
775 use tempfile::TempDir;
776
777 use super::*;
778
779 #[test]
780 fn test_translator_new() {
781 let translator = Translator::new();
782 assert_eq!(translator.workspace_roots.len(), 0);
783 assert_eq!(translator.lsp_clients.len(), 0);
784 }
785
786 #[test]
787 fn test_set_workspace_roots() {
788 let mut translator = Translator::new();
789 let roots = vec![PathBuf::from("/test/root1"), PathBuf::from("/test/root2")];
790 translator.set_workspace_roots(roots.clone());
791 assert_eq!(translator.workspace_roots, roots);
792 }
793
794 #[test]
795 fn test_validate_path_no_workspace_roots() {
796 let translator = Translator::new();
797 let temp_dir = TempDir::new().unwrap();
798 let test_file = temp_dir.path().join("test.rs");
799 fs::write(&test_file, "fn main() {}").unwrap();
800
801 let result = translator.validate_path(&test_file);
803 assert!(result.is_ok());
804 }
805
806 #[test]
807 fn test_validate_path_within_workspace() {
808 let mut translator = Translator::new();
809 let temp_dir = TempDir::new().unwrap();
810 let workspace_root = temp_dir.path().to_path_buf();
811 translator.set_workspace_roots(vec![workspace_root]);
812
813 let test_file = temp_dir.path().join("test.rs");
814 fs::write(&test_file, "fn main() {}").unwrap();
815
816 let result = translator.validate_path(&test_file);
817 assert!(result.is_ok());
818 }
819
820 #[test]
821 fn test_validate_path_outside_workspace() {
822 let mut translator = Translator::new();
823 let temp_dir1 = TempDir::new().unwrap();
824 let temp_dir2 = TempDir::new().unwrap();
825
826 translator.set_workspace_roots(vec![temp_dir1.path().to_path_buf()]);
828
829 let test_file = temp_dir2.path().join("test.rs");
831 fs::write(&test_file, "fn main() {}").unwrap();
832
833 let result = translator.validate_path(&test_file);
834 assert!(matches!(result, Err(Error::PathOutsideWorkspace(_))));
835 }
836
837 #[test]
838 fn test_normalize_range() {
839 let lsp_range = lsp_types::Range {
840 start: lsp_types::Position {
841 line: 0,
842 character: 0,
843 },
844 end: lsp_types::Position {
845 line: 2,
846 character: 5,
847 },
848 };
849
850 let mcp_range = normalize_range(lsp_range);
851 assert_eq!(mcp_range.start.line, 1);
852 assert_eq!(mcp_range.start.character, 1);
853 assert_eq!(mcp_range.end.line, 3);
854 assert_eq!(mcp_range.end.character, 6);
855 }
856
857 #[test]
858 fn test_extract_hover_contents_string() {
859 let marked_string = lsp_types::MarkedString::String("Test hover".to_string());
860 let contents = lsp_types::HoverContents::Scalar(marked_string);
861 let result = extract_hover_contents(contents);
862 assert_eq!(result, "Test hover");
863 }
864
865 #[test]
866 fn test_extract_hover_contents_language_string() {
867 let marked_string = lsp_types::MarkedString::LanguageString(lsp_types::LanguageString {
868 language: "rust".to_string(),
869 value: "fn main() {}".to_string(),
870 });
871 let contents = lsp_types::HoverContents::Scalar(marked_string);
872 let result = extract_hover_contents(contents);
873 assert_eq!(result, "```rust\nfn main() {}\n```");
874 }
875
876 #[test]
877 fn test_extract_hover_contents_markup() {
878 let markup = lsp_types::MarkupContent {
879 kind: lsp_types::MarkupKind::Markdown,
880 value: "# Documentation".to_string(),
881 };
882 let contents = lsp_types::HoverContents::Markup(markup);
883 let result = extract_hover_contents(contents);
884 assert_eq!(result, "# Documentation");
885 }
886}