mcpls_core/bridge/
translator.rs

1//! MCP to LSP translation layer.
2
3use 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/// Translator handles MCP tool calls by converting them to LSP requests.
23#[derive(Debug)]
24pub struct Translator {
25    /// LSP clients indexed by language ID.
26    lsp_clients: HashMap<String, LspClient>,
27    /// Document state tracker.
28    document_tracker: DocumentTracker,
29    /// Allowed workspace roots for path validation.
30    workspace_roots: Vec<PathBuf>,
31}
32
33impl Translator {
34    /// Create a new translator.
35    #[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    /// Set the workspace roots for path validation.
45    pub fn set_workspace_roots(&mut self, roots: Vec<PathBuf>) {
46        self.workspace_roots = roots;
47    }
48
49    /// Register an LSP client for a language.
50    pub fn register_client(&mut self, language_id: String, client: LspClient) {
51        self.lsp_clients.insert(language_id, client);
52    }
53
54    /// Get the document tracker.
55    #[must_use]
56    pub const fn document_tracker(&self) -> &DocumentTracker {
57        &self.document_tracker
58    }
59
60    /// Get a mutable reference to the document tracker.
61    pub const fn document_tracker_mut(&mut self) -> &mut DocumentTracker {
62        &mut self.document_tracker
63    }
64
65    // TODO: These methods will be implemented in Phase 3-5
66    // Initialize and shutdown are now handled by LspServer in lifecycle.rs
67
68    // Future implementation will use LspServer instead of LspClient directly
69}
70
71impl Default for Translator {
72    fn default() -> Self {
73        Self::new()
74    }
75}
76
77/// Position in a document (1-based for MCP).
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct Position2D {
80    /// Line number (1-based).
81    pub line: u32,
82    /// Character offset (1-based).
83    pub character: u32,
84}
85
86/// Range in a document (1-based for MCP).
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct Range {
89    /// Start position.
90    pub start: Position2D,
91    /// End position.
92    pub end: Position2D,
93}
94
95/// Location in a document.
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct Location {
98    /// URI of the document.
99    pub uri: String,
100    /// Range within the document.
101    pub range: Range,
102}
103
104/// Result of a hover request.
105#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct HoverResult {
107    /// Hover contents as markdown string.
108    pub contents: String,
109    /// Optional range the hover applies to.
110    pub range: Option<Range>,
111}
112
113/// Result of a definition request.
114#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct DefinitionResult {
116    /// Locations of the definition.
117    pub locations: Vec<Location>,
118}
119
120/// Result of a references request.
121#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct ReferencesResult {
123    /// Locations of all references.
124    pub locations: Vec<Location>,
125}
126
127/// Diagnostic severity.
128#[derive(Debug, Clone, Serialize, Deserialize)]
129#[serde(rename_all = "lowercase")]
130pub enum DiagnosticSeverity {
131    /// Error diagnostic.
132    Error,
133    /// Warning diagnostic.
134    Warning,
135    /// Informational diagnostic.
136    Information,
137    /// Hint diagnostic.
138    Hint,
139}
140
141/// A single diagnostic.
142#[derive(Debug, Clone, Serialize, Deserialize)]
143pub struct Diagnostic {
144    /// Range where the diagnostic applies.
145    pub range: Range,
146    /// Severity of the diagnostic.
147    pub severity: DiagnosticSeverity,
148    /// Diagnostic message.
149    pub message: String,
150    /// Optional diagnostic code.
151    pub code: Option<String>,
152}
153
154/// Result of a diagnostics request.
155#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct DiagnosticsResult {
157    /// List of diagnostics for the document.
158    pub diagnostics: Vec<Diagnostic>,
159}
160
161/// A text edit operation.
162#[derive(Debug, Clone, Serialize, Deserialize)]
163pub struct TextEdit {
164    /// Range to replace.
165    pub range: Range,
166    /// New text.
167    pub new_text: String,
168}
169
170/// Changes to a document.
171#[derive(Debug, Clone, Serialize, Deserialize)]
172pub struct DocumentChanges {
173    /// URI of the document.
174    pub uri: String,
175    /// List of edits to apply.
176    pub edits: Vec<TextEdit>,
177}
178
179/// Result of a rename request.
180#[derive(Debug, Clone, Serialize, Deserialize)]
181pub struct RenameResult {
182    /// Changes to apply across documents.
183    pub changes: Vec<DocumentChanges>,
184}
185
186/// A completion item.
187#[derive(Debug, Clone, Serialize, Deserialize)]
188pub struct Completion {
189    /// Label of the completion.
190    pub label: String,
191    /// Kind of completion.
192    pub kind: Option<String>,
193    /// Detail information.
194    pub detail: Option<String>,
195    /// Documentation.
196    pub documentation: Option<String>,
197}
198
199/// Result of a completions request.
200#[derive(Debug, Clone, Serialize, Deserialize)]
201pub struct CompletionsResult {
202    /// List of completion items.
203    pub items: Vec<Completion>,
204}
205
206/// A document symbol.
207#[derive(Debug, Clone, Serialize, Deserialize)]
208pub struct Symbol {
209    /// Name of the symbol.
210    pub name: String,
211    /// Kind of symbol.
212    pub kind: String,
213    /// Range of the symbol.
214    pub range: Range,
215    /// Selection range (identifier location).
216    pub selection_range: Range,
217    /// Child symbols.
218    #[serde(skip_serializing_if = "Option::is_none")]
219    pub children: Option<Vec<Self>>,
220}
221
222/// Result of a document symbols request.
223#[derive(Debug, Clone, Serialize, Deserialize)]
224pub struct DocumentSymbolsResult {
225    /// List of symbols in the document.
226    pub symbols: Vec<Symbol>,
227}
228
229/// Result of a format document request.
230#[derive(Debug, Clone, Serialize, Deserialize)]
231pub struct FormatDocumentResult {
232    /// List of edits to format the document.
233    pub edits: Vec<TextEdit>,
234}
235
236impl Translator {
237    /// Validate that a path is within allowed workspace boundaries.
238    ///
239    /// # Errors
240    ///
241    /// Returns `Error::PathOutsideWorkspace` if the path is outside all workspace roots.
242    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 no workspace roots configured, allow any path (backward compatibility)
249        if self.workspace_roots.is_empty() {
250            return Ok(canonical);
251        }
252
253        // Check if path is within any workspace root
254        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    /// Get a cloned LSP client for a file path based on language detection.
266    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    /// Handle hover request.
275    ///
276    /// # Errors
277    ///
278    /// Returns an error if the LSP request fails or the file cannot be opened.
279    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    /// Handle definition request.
323    ///
324    /// # Errors
325    ///
326    /// Returns an error if the LSP request fails or the file cannot be opened.
327    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    /// Handle references request.
383    ///
384    /// # Errors
385    ///
386    /// Returns an error if the LSP request fails or the file cannot be opened.
387    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    /// Handle diagnostics request.
436    ///
437    /// # Errors
438    ///
439    /// Returns an error if the LSP request fails or the file cannot be opened.
440    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    /// Handle rename request.
499    ///
500    /// # Errors
501    ///
502    /// Returns an error if the LSP request fails or the file cannot be opened.
503    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    /// Handle completions request.
560    ///
561    /// # Errors
562    ///
563    /// Returns an error if the LSP request fails or the file cannot be opened.
564    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    /// Handle document symbols request.
625    ///
626    /// # Errors
627    ///
628    /// Returns an error if the LSP request fails or the file cannot be opened.
629    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    /// Handle format document request.
673    ///
674    /// # Errors
675    ///
676    /// Returns an error if the LSP request fails or the file cannot be opened.
677    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
722/// Extract hover contents as markdown string.
723fn 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
735/// Convert a marked string to a plain string.
736fn 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
743/// Convert LSP range to MCP range (0-based to 1-based).
744const 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
757/// Convert LSP document symbol to MCP symbol.
758fn 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        // With no workspace roots, any valid path should be accepted
802        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        // Set workspace root to temp_dir1
827        translator.set_workspace_roots(vec![temp_dir1.path().to_path_buf()]);
828
829        // Create file in temp_dir2 (outside workspace)
830        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}