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    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/// Translator handles MCP tool calls by converting them to LSP requests.
26#[derive(Debug)]
27pub struct Translator {
28    /// LSP clients indexed by language ID.
29    lsp_clients: HashMap<String, LspClient>,
30    /// Document state tracker.
31    document_tracker: DocumentTracker,
32    /// Allowed workspace roots for path validation.
33    workspace_roots: Vec<PathBuf>,
34}
35
36impl Translator {
37    /// Create a new translator.
38    #[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    /// Set the workspace roots for path validation.
48    pub fn set_workspace_roots(&mut self, roots: Vec<PathBuf>) {
49        self.workspace_roots = roots;
50    }
51
52    /// Register an LSP client for a language.
53    pub fn register_client(&mut self, language_id: String, client: LspClient) {
54        self.lsp_clients.insert(language_id, client);
55    }
56
57    /// Get the document tracker.
58    #[must_use]
59    pub const fn document_tracker(&self) -> &DocumentTracker {
60        &self.document_tracker
61    }
62
63    /// Get a mutable reference to the document tracker.
64    pub const fn document_tracker_mut(&mut self) -> &mut DocumentTracker {
65        &mut self.document_tracker
66    }
67
68    // TODO: These methods will be implemented in Phase 3-5
69    // Initialize and shutdown are now handled by LspServer in lifecycle.rs
70
71    // Future implementation will use LspServer instead of LspClient directly
72}
73
74impl Default for Translator {
75    fn default() -> Self {
76        Self::new()
77    }
78}
79
80/// Position in a document (1-based for MCP).
81#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct Position2D {
83    /// Line number (1-based).
84    pub line: u32,
85    /// Character offset (1-based).
86    pub character: u32,
87}
88
89/// Range in a document (1-based for MCP).
90#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct Range {
92    /// Start position.
93    pub start: Position2D,
94    /// End position.
95    pub end: Position2D,
96}
97
98/// Location in a document.
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct Location {
101    /// URI of the document.
102    pub uri: String,
103    /// Range within the document.
104    pub range: Range,
105}
106
107/// Result of a hover request.
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct HoverResult {
110    /// Hover contents as markdown string.
111    pub contents: String,
112    /// Optional range the hover applies to.
113    pub range: Option<Range>,
114}
115
116/// Result of a definition request.
117#[derive(Debug, Clone, Serialize, Deserialize)]
118pub struct DefinitionResult {
119    /// Locations of the definition.
120    pub locations: Vec<Location>,
121}
122
123/// Result of a references request.
124#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct ReferencesResult {
126    /// Locations of all references.
127    pub locations: Vec<Location>,
128}
129
130/// Diagnostic severity.
131#[derive(Debug, Clone, Serialize, Deserialize)]
132#[serde(rename_all = "lowercase")]
133pub enum DiagnosticSeverity {
134    /// Error diagnostic.
135    Error,
136    /// Warning diagnostic.
137    Warning,
138    /// Informational diagnostic.
139    Information,
140    /// Hint diagnostic.
141    Hint,
142}
143
144/// A single diagnostic.
145#[derive(Debug, Clone, Serialize, Deserialize)]
146pub struct Diagnostic {
147    /// Range where the diagnostic applies.
148    pub range: Range,
149    /// Severity of the diagnostic.
150    pub severity: DiagnosticSeverity,
151    /// Diagnostic message.
152    pub message: String,
153    /// Optional diagnostic code.
154    pub code: Option<String>,
155}
156
157/// Result of a diagnostics request.
158#[derive(Debug, Clone, Serialize, Deserialize)]
159pub struct DiagnosticsResult {
160    /// List of diagnostics for the document.
161    pub diagnostics: Vec<Diagnostic>,
162}
163
164/// A text edit operation.
165#[derive(Debug, Clone, Serialize, Deserialize)]
166pub struct TextEdit {
167    /// Range to replace.
168    pub range: Range,
169    /// New text.
170    pub new_text: String,
171}
172
173/// Changes to a document.
174#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct DocumentChanges {
176    /// URI of the document.
177    pub uri: String,
178    /// List of edits to apply.
179    pub edits: Vec<TextEdit>,
180}
181
182/// Result of a rename request.
183#[derive(Debug, Clone, Serialize, Deserialize)]
184pub struct RenameResult {
185    /// Changes to apply across documents.
186    pub changes: Vec<DocumentChanges>,
187}
188
189/// A completion item.
190#[derive(Debug, Clone, Serialize, Deserialize)]
191pub struct Completion {
192    /// Label of the completion.
193    pub label: String,
194    /// Kind of completion.
195    pub kind: Option<String>,
196    /// Detail information.
197    pub detail: Option<String>,
198    /// Documentation.
199    pub documentation: Option<String>,
200}
201
202/// Result of a completions request.
203#[derive(Debug, Clone, Serialize, Deserialize)]
204pub struct CompletionsResult {
205    /// List of completion items.
206    pub items: Vec<Completion>,
207}
208
209/// A document symbol.
210#[derive(Debug, Clone, Serialize, Deserialize)]
211pub struct Symbol {
212    /// Name of the symbol.
213    pub name: String,
214    /// Kind of symbol.
215    pub kind: String,
216    /// Range of the symbol.
217    pub range: Range,
218    /// Selection range (identifier location).
219    pub selection_range: Range,
220    /// Child symbols.
221    #[serde(skip_serializing_if = "Option::is_none")]
222    pub children: Option<Vec<Self>>,
223}
224
225/// Result of a document symbols request.
226#[derive(Debug, Clone, Serialize, Deserialize)]
227pub struct DocumentSymbolsResult {
228    /// List of symbols in the document.
229    pub symbols: Vec<Symbol>,
230}
231
232/// Result of a format document request.
233#[derive(Debug, Clone, Serialize, Deserialize)]
234pub struct FormatDocumentResult {
235    /// List of edits to format the document.
236    pub edits: Vec<TextEdit>,
237}
238
239/// A workspace symbol.
240#[derive(Debug, Clone, Serialize, Deserialize)]
241pub struct WorkspaceSymbol {
242    /// Name of the symbol.
243    pub name: String,
244    /// Kind of symbol.
245    pub kind: String,
246    /// Location of the symbol.
247    pub location: Location,
248    /// Optional container name (parent scope).
249    #[serde(skip_serializing_if = "Option::is_none")]
250    pub container_name: Option<String>,
251}
252
253/// Result of workspace symbol search.
254#[derive(Debug, Clone, Serialize, Deserialize)]
255pub struct WorkspaceSymbolResult {
256    /// List of symbols found.
257    pub symbols: Vec<WorkspaceSymbol>,
258}
259
260/// A single code action.
261#[derive(Debug, Clone, Serialize, Deserialize)]
262pub struct CodeAction {
263    /// Title of the code action.
264    pub title: String,
265    /// Kind of code action (quickfix, refactor, etc.).
266    #[serde(skip_serializing_if = "Option::is_none")]
267    pub kind: Option<String>,
268    /// Diagnostics that this action resolves.
269    #[serde(skip_serializing_if = "Vec::is_empty", default)]
270    pub diagnostics: Vec<Diagnostic>,
271    /// Workspace edit to apply.
272    #[serde(skip_serializing_if = "Option::is_none")]
273    pub edit: Option<WorkspaceEditDescription>,
274    /// Command to execute.
275    #[serde(skip_serializing_if = "Option::is_none")]
276    pub command: Option<CommandDescription>,
277    /// Whether this is the preferred action.
278    #[serde(default)]
279    pub is_preferred: bool,
280}
281
282/// Description of a workspace edit.
283#[derive(Debug, Clone, Serialize, Deserialize)]
284pub struct WorkspaceEditDescription {
285    /// Changes to apply to documents.
286    pub changes: Vec<DocumentChanges>,
287}
288
289/// Description of a command.
290#[derive(Debug, Clone, Serialize, Deserialize)]
291pub struct CommandDescription {
292    /// Title of the command.
293    pub title: String,
294    /// Command identifier.
295    pub command: String,
296    /// Command arguments.
297    #[serde(skip_serializing_if = "Vec::is_empty", default)]
298    pub arguments: Vec<serde_json::Value>,
299}
300
301/// Result of code actions request.
302#[derive(Debug, Clone, Serialize, Deserialize)]
303pub struct CodeActionsResult {
304    /// Available code actions.
305    pub actions: Vec<CodeAction>,
306}
307
308/// A call hierarchy item.
309#[derive(Debug, Clone, Serialize, Deserialize)]
310pub struct CallHierarchyItemResult {
311    /// Name of the symbol.
312    pub name: String,
313    /// Kind of symbol.
314    pub kind: String,
315    /// More detail for this item.
316    #[serde(skip_serializing_if = "Option::is_none")]
317    pub detail: Option<String>,
318    /// URI of the document.
319    pub uri: String,
320    /// Range of the symbol.
321    pub range: Range,
322    /// Selection range (identifier location).
323    pub selection_range: Range,
324    /// Opaque data to pass to incoming/outgoing calls.
325    #[serde(skip_serializing_if = "Option::is_none")]
326    pub data: Option<serde_json::Value>,
327}
328
329/// Result of call hierarchy prepare request.
330#[derive(Debug, Clone, Serialize, Deserialize)]
331pub struct CallHierarchyPrepareResult {
332    /// List of callable items at the position.
333    pub items: Vec<CallHierarchyItemResult>,
334}
335
336/// An incoming call (caller of the current item).
337#[derive(Debug, Clone, Serialize, Deserialize)]
338pub struct IncomingCall {
339    /// The item that calls the current item.
340    pub from: CallHierarchyItemResult,
341    /// Ranges where the call occurs.
342    pub from_ranges: Vec<Range>,
343}
344
345/// Result of incoming calls request.
346#[derive(Debug, Clone, Serialize, Deserialize)]
347pub struct IncomingCallsResult {
348    /// List of incoming calls.
349    pub calls: Vec<IncomingCall>,
350}
351
352/// An outgoing call (callee from the current item).
353#[derive(Debug, Clone, Serialize, Deserialize)]
354pub struct OutgoingCall {
355    /// The item being called.
356    pub to: CallHierarchyItemResult,
357    /// Ranges where the call occurs.
358    pub from_ranges: Vec<Range>,
359}
360
361/// Result of outgoing calls request.
362#[derive(Debug, Clone, Serialize, Deserialize)]
363pub struct OutgoingCallsResult {
364    /// List of outgoing calls.
365    pub calls: Vec<OutgoingCall>,
366}
367
368/// Maximum allowed position value for validation.
369const MAX_POSITION_VALUE: u32 = 1_000_000;
370/// Maximum allowed range size in lines.
371const MAX_RANGE_LINES: u32 = 10_000;
372
373impl Translator {
374    /// Validate that a path is within allowed workspace boundaries.
375    ///
376    /// # Errors
377    ///
378    /// Returns `Error::PathOutsideWorkspace` if the path is outside all workspace roots.
379    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 no workspace roots configured, allow any path (backward compatibility)
386        if self.workspace_roots.is_empty() {
387            return Ok(canonical);
388        }
389
390        // Check if path is within any workspace root
391        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    /// Get a cloned LSP client for a file path based on language detection.
403    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    /// Parse and validate a file URI, returning the validated path.
412    ///
413    /// # Errors
414    ///
415    /// Returns an error if:
416    /// - The URI doesn't have a file:// scheme
417    /// - The path is outside workspace boundaries
418    fn parse_file_uri(&self, uri: &lsp_types::Uri) -> Result<PathBuf> {
419        let uri_str = uri.as_str();
420
421        // Validate file:// scheme
422        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        // Extract path after file://
429        let path_str = &uri_str["file://".len()..];
430
431        // Handle Windows paths: file:///C:/path -> /C:/path -> C:/path
432        // On Windows, URIs have format file:///C:/path, so we need to strip the leading /
433        #[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        // Validate path is within workspace
446        self.validate_path(&path)
447    }
448
449    /// Handle hover request.
450    ///
451    /// # Errors
452    ///
453    /// Returns an error if the LSP request fails or the file cannot be opened.
454    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    /// Handle definition request.
498    ///
499    /// # Errors
500    ///
501    /// Returns an error if the LSP request fails or the file cannot be opened.
502    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    /// Handle references request.
558    ///
559    /// # Errors
560    ///
561    /// Returns an error if the LSP request fails or the file cannot be opened.
562    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    /// Handle diagnostics request.
611    ///
612    /// # Errors
613    ///
614    /// Returns an error if the LSP request fails or the file cannot be opened.
615    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    /// Handle rename request.
674    ///
675    /// # Errors
676    ///
677    /// Returns an error if the LSP request fails or the file cannot be opened.
678    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    /// Handle completions request.
735    ///
736    /// # Errors
737    ///
738    /// Returns an error if the LSP request fails or the file cannot be opened.
739    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    /// Handle document symbols request.
800    ///
801    /// # Errors
802    ///
803    /// Returns an error if the LSP request fails or the file cannot be opened.
804    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    /// Handle format document request.
848    ///
849    /// # Errors
850    ///
851    /// Returns an error if the LSP request fails or the file cannot be opened.
852    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    /// Handle workspace symbol search.
897    ///
898    /// # Errors
899    ///
900    /// Returns an error if the LSP request fails or no server is configured.
901    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        // Validate query length
938        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        // Validate kind filter
946        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        // Workspace search requires at least one LSP client
958        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        // Apply kind filter if specified
991        if let Some(kind) = kind_filter {
992            symbols.retain(|s| s.kind.eq_ignore_ascii_case(&kind));
993        }
994
995        // Limit results
996        symbols.truncate(limit as usize);
997
998        Ok(WorkspaceSymbolResult { symbols })
999    }
1000
1001    /// Handle code actions request.
1002    ///
1003    /// # Errors
1004    ///
1005    /// Returns an error if the LSP request fails or the file cannot be opened.
1006    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        // Validate kind filter
1026        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        // Validate range
1038        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        // Validate position upper bounds
1045        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        // Validate range size
1056        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        // Build context with optional kind filter
1082        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    /// Handle call hierarchy prepare request.
1130    ///
1131    /// # Errors
1132    ///
1133    /// Returns an error if the LSP request fails or the file cannot be opened.
1134    pub async fn handle_call_hierarchy_prepare(
1135        &mut self,
1136        file_path: String,
1137        line: u32,
1138        character: u32,
1139    ) -> Result<CallHierarchyPrepareResult> {
1140        // Validate position bounds
1141        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        // Pre-allocate and build result
1180        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    /// Handle incoming calls request.
1190    ///
1191    /// # Errors
1192    ///
1193    /// Returns an error if the LSP request fails or the item is invalid.
1194    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        // Parse and validate the URI
1202        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        // Pre-allocate and build result
1217        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    /// Handle outgoing calls request.
1239    ///
1240    /// # Errors
1241    ///
1242    /// Returns an error if the LSP request fails or the item is invalid.
1243    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        // Parse and validate the URI
1251        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        // Pre-allocate and build result
1266        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
1288/// Extract hover contents as markdown string.
1289fn 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
1301/// Convert a marked string to a plain string.
1302fn 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
1309/// Convert LSP range to MCP range (0-based to 1-based).
1310const 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
1323/// Convert LSP document symbol to MCP symbol.
1324fn 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
1336/// Convert LSP call hierarchy item to MCP call hierarchy item.
1337fn 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
1349/// Convert LSP code action to MCP code action.
1350fn 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        // With no workspace roots, any valid path should be accepted
1448        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        // Set workspace root to temp_dir1
1473        translator.set_workspace_roots(vec![temp_dir1.path().to_path_buf()]);
1474
1475        // Create file in temp_dir2 (outside workspace)
1476        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        // Will fail due to no LSP server, but validates kind is accepted
1578        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        // Empty range (same position) should be valid
1679        let result = translator
1680            .handle_code_actions(test_file.to_str().unwrap().to_string(), 1, 5, 1, 5, None)
1681            .await;
1682        // Will fail due to no LSP server, but validates range is accepted
1683        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        // Use url crate for cross-platform file URI creation
1963        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}