Skip to main content

mcpls_core/bridge/
translator.rs

1//! MCP to LSP translation layer.
2
3use std::collections::{HashMap, HashSet};
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    InlayHintLabel, InlayHintParams, MarkedString, PartialResultParams, ReferenceContext,
13    ReferenceParams, RenameParams as LspRenameParams,
14    SignatureHelpParams as LspSignatureHelpParams, TextDocumentIdentifier,
15    TextDocumentPositionParams, WorkDoneProgressParams, WorkspaceEdit,
16    WorkspaceSymbolParams as LspWorkspaceSymbolParams,
17};
18use serde::{Deserialize, Serialize};
19use tokio::time::Duration;
20
21use super::state::{ResourceLimits, detect_language, path_to_uri};
22use super::{DocumentTracker, NotificationCache};
23use crate::bridge::encoding::mcp_to_lsp_position;
24use crate::error::{Error, Result};
25use crate::lsp::{LspClient, LspServer};
26
27/// Translator handles MCP tool calls by converting them to LSP requests.
28#[derive(Debug)]
29pub struct Translator {
30    /// LSP clients indexed by language ID.
31    lsp_clients: HashMap<String, LspClient>,
32    /// LSP servers indexed by language ID (held for lifetime management).
33    lsp_servers: HashMap<String, LspServer>,
34    /// Document state tracker.
35    document_tracker: DocumentTracker,
36    /// Notification cache for LSP server notifications.
37    notification_cache: NotificationCache,
38    /// Allowed workspace roots for path validation.
39    workspace_roots: Vec<PathBuf>,
40    /// Custom file extension to language ID mappings.
41    extension_map: HashMap<String, String>,
42    /// Languages that are configured + applicable but whose LSP server may not
43    /// have finished initializing yet (background init). Used to return a clear
44    /// "still initializing" error instead of "no server configured".
45    expected_languages: HashSet<String>,
46}
47
48impl Translator {
49    /// Create a new translator.
50    #[must_use]
51    pub fn new() -> Self {
52        Self {
53            lsp_clients: HashMap::new(),
54            lsp_servers: HashMap::new(),
55            document_tracker: DocumentTracker::new(ResourceLimits::default(), HashMap::new()),
56            notification_cache: NotificationCache::new(),
57            workspace_roots: vec![],
58            extension_map: HashMap::new(),
59            expected_languages: HashSet::new(),
60        }
61    }
62
63    /// Set the workspace roots for path validation.
64    pub fn set_workspace_roots(&mut self, roots: Vec<PathBuf>) {
65        self.workspace_roots = roots;
66    }
67
68    /// Mark the set of languages whose LSP servers are expected (configured +
69    /// applicable) but may still be initializing in the background.
70    pub fn set_expected_languages(&mut self, languages: HashSet<String>) {
71        self.expected_languages = languages;
72    }
73
74    /// Clear the expected-languages set (e.g. after background init failed).
75    pub fn clear_expected_languages(&mut self) {
76        self.expected_languages.clear();
77    }
78
79    /// Configure custom file extension mappings.
80    ///
81    /// This method sets the extension map and updates the document tracker
82    /// to use the same mappings for language detection.
83    #[must_use]
84    pub fn with_extensions(mut self, extension_map: HashMap<String, String>) -> Self {
85        self.document_tracker =
86            DocumentTracker::new(ResourceLimits::default(), extension_map.clone());
87        self.extension_map = extension_map;
88        self
89    }
90
91    /// Register an LSP client for a language.
92    pub fn register_client(&mut self, language_id: String, client: LspClient) {
93        self.lsp_clients.insert(language_id, client);
94    }
95
96    /// Register an LSP server for a language.
97    pub fn register_server(&mut self, language_id: String, server: LspServer) {
98        self.lsp_servers.insert(language_id, server);
99    }
100
101    /// Get the document tracker.
102    #[must_use]
103    pub const fn document_tracker(&self) -> &DocumentTracker {
104        &self.document_tracker
105    }
106
107    /// Get a mutable reference to the document tracker.
108    pub const fn document_tracker_mut(&mut self) -> &mut DocumentTracker {
109        &mut self.document_tracker
110    }
111
112    /// Get the notification cache.
113    #[must_use]
114    pub const fn notification_cache(&self) -> &NotificationCache {
115        &self.notification_cache
116    }
117
118    /// Get a mutable reference to the notification cache.
119    pub const fn notification_cache_mut(&mut self) -> &mut NotificationCache {
120        &mut self.notification_cache
121    }
122
123    // TODO: These methods will be implemented in Phase 3-5
124    // Initialize and shutdown are now handled by LspServer in lifecycle.rs
125
126    // Future implementation will use LspServer instead of LspClient directly
127}
128
129impl Default for Translator {
130    fn default() -> Self {
131        Self::new()
132    }
133}
134
135#[derive(Debug, Serialize)]
136#[serde(rename_all = "camelCase")]
137struct DiagnosticRequestParams {
138    text_document: TextDocumentIdentifier,
139    #[serde(skip_serializing_if = "Option::is_none")]
140    identifier: Option<String>,
141    #[serde(skip_serializing_if = "Option::is_none")]
142    previous_result_id: Option<String>,
143    #[serde(flatten)]
144    work_done_progress_params: WorkDoneProgressParams,
145    #[serde(flatten)]
146    partial_result_params: PartialResultParams,
147}
148
149fn diagnostic_request_params(text_document: TextDocumentIdentifier) -> DiagnosticRequestParams {
150    DiagnosticRequestParams {
151        text_document,
152        identifier: None,
153        previous_result_id: None,
154        work_done_progress_params: WorkDoneProgressParams::default(),
155        partial_result_params: PartialResultParams::default(),
156    }
157}
158
159/// Position in a document (1-based for MCP).
160#[derive(Debug, Clone, Serialize, Deserialize)]
161pub struct Position2D {
162    /// Line number (1-based).
163    pub line: u32,
164    /// Character offset (1-based).
165    pub character: u32,
166}
167
168/// Range in a document (1-based for MCP).
169#[derive(Debug, Clone, Serialize, Deserialize)]
170pub struct Range {
171    /// Start position.
172    pub start: Position2D,
173    /// End position.
174    pub end: Position2D,
175}
176
177/// Location in a document.
178#[derive(Debug, Clone, Serialize, Deserialize)]
179pub struct Location {
180    /// URI of the document.
181    pub uri: String,
182    /// Range within the document.
183    pub range: Range,
184}
185
186/// Result of a hover request.
187#[derive(Debug, Clone, Serialize, Deserialize)]
188pub struct HoverResult {
189    /// Hover contents as markdown string.
190    pub contents: String,
191    /// Optional range the hover applies to.
192    pub range: Option<Range>,
193}
194
195/// Result of a definition request.
196#[derive(Debug, Clone, Serialize, Deserialize)]
197pub struct DefinitionResult {
198    /// Locations of the definition.
199    pub locations: Vec<Location>,
200}
201
202/// Result of a references request.
203#[derive(Debug, Clone, Serialize, Deserialize)]
204pub struct ReferencesResult {
205    /// Locations of all references.
206    pub locations: Vec<Location>,
207}
208
209/// Diagnostic severity.
210#[derive(Debug, Clone, Serialize, Deserialize)]
211#[serde(rename_all = "lowercase")]
212pub enum DiagnosticSeverity {
213    /// Error diagnostic.
214    Error,
215    /// Warning diagnostic.
216    Warning,
217    /// Informational diagnostic.
218    Information,
219    /// Hint diagnostic.
220    Hint,
221}
222
223/// A single diagnostic.
224#[derive(Debug, Clone, Serialize, Deserialize)]
225pub struct Diagnostic {
226    /// Range where the diagnostic applies.
227    pub range: Range,
228    /// Severity of the diagnostic.
229    pub severity: DiagnosticSeverity,
230    /// Diagnostic message.
231    pub message: String,
232    /// Optional diagnostic code.
233    pub code: Option<String>,
234}
235
236/// Result of a diagnostics request.
237#[derive(Debug, Clone, Serialize, Deserialize)]
238pub struct DiagnosticsResult {
239    /// List of diagnostics for the document.
240    pub diagnostics: Vec<Diagnostic>,
241}
242
243/// A text edit operation.
244#[derive(Debug, Clone, Serialize, Deserialize)]
245pub struct TextEdit {
246    /// Range to replace.
247    pub range: Range,
248    /// New text.
249    pub new_text: String,
250}
251
252/// Changes to a document.
253#[derive(Debug, Clone, Serialize, Deserialize)]
254pub struct DocumentChanges {
255    /// URI of the document.
256    pub uri: String,
257    /// List of edits to apply.
258    pub edits: Vec<TextEdit>,
259}
260
261/// Result of a rename request.
262#[derive(Debug, Clone, Serialize, Deserialize)]
263pub struct RenameResult {
264    /// Changes to apply across documents.
265    pub changes: Vec<DocumentChanges>,
266}
267
268/// A completion item.
269#[derive(Debug, Clone, Serialize, Deserialize)]
270pub struct Completion {
271    /// Label of the completion.
272    pub label: String,
273    /// Kind of completion.
274    pub kind: Option<String>,
275    /// Detail information.
276    pub detail: Option<String>,
277    /// Documentation.
278    pub documentation: Option<String>,
279}
280
281/// Result of a completions request.
282#[derive(Debug, Clone, Serialize, Deserialize)]
283pub struct CompletionsResult {
284    /// List of completion items.
285    pub items: Vec<Completion>,
286}
287
288/// A document symbol.
289#[derive(Debug, Clone, Serialize, Deserialize)]
290pub struct Symbol {
291    /// Name of the symbol.
292    pub name: String,
293    /// Kind of symbol.
294    pub kind: String,
295    /// Range of the symbol.
296    pub range: Range,
297    /// Selection range (identifier location).
298    pub selection_range: Range,
299    /// Child symbols.
300    #[serde(skip_serializing_if = "Option::is_none")]
301    pub children: Option<Vec<Self>>,
302}
303
304/// Result of a document symbols request.
305#[derive(Debug, Clone, Serialize, Deserialize)]
306pub struct DocumentSymbolsResult {
307    /// List of symbols in the document.
308    pub symbols: Vec<Symbol>,
309}
310
311/// Result of a format document request.
312#[derive(Debug, Clone, Serialize, Deserialize)]
313pub struct FormatDocumentResult {
314    /// List of edits to format the document.
315    pub edits: Vec<TextEdit>,
316}
317
318/// A workspace symbol.
319#[derive(Debug, Clone, Serialize, Deserialize)]
320pub struct WorkspaceSymbol {
321    /// Name of the symbol.
322    pub name: String,
323    /// Kind of symbol.
324    pub kind: String,
325    /// Location of the symbol.
326    pub location: Location,
327    /// Optional container name (parent scope).
328    #[serde(skip_serializing_if = "Option::is_none")]
329    pub container_name: Option<String>,
330}
331
332/// Result of workspace symbol search.
333#[derive(Debug, Clone, Serialize, Deserialize)]
334pub struct WorkspaceSymbolResult {
335    /// List of symbols found.
336    pub symbols: Vec<WorkspaceSymbol>,
337}
338
339/// A single code action.
340#[derive(Debug, Clone, Serialize, Deserialize)]
341pub struct CodeAction {
342    /// Title of the code action.
343    pub title: String,
344    /// Kind of code action (quickfix, refactor, etc.).
345    #[serde(skip_serializing_if = "Option::is_none")]
346    pub kind: Option<String>,
347    /// Diagnostics that this action resolves.
348    #[serde(skip_serializing_if = "Vec::is_empty", default)]
349    pub diagnostics: Vec<Diagnostic>,
350    /// Workspace edit to apply.
351    #[serde(skip_serializing_if = "Option::is_none")]
352    pub edit: Option<WorkspaceEditDescription>,
353    /// Command to execute.
354    #[serde(skip_serializing_if = "Option::is_none")]
355    pub command: Option<CommandDescription>,
356    /// Whether this is the preferred action.
357    #[serde(default)]
358    pub is_preferred: bool,
359}
360
361/// Description of a workspace edit.
362#[derive(Debug, Clone, Serialize, Deserialize)]
363pub struct WorkspaceEditDescription {
364    /// Changes to apply to documents.
365    pub changes: Vec<DocumentChanges>,
366}
367
368/// Description of a command.
369#[derive(Debug, Clone, Serialize, Deserialize)]
370pub struct CommandDescription {
371    /// Title of the command.
372    pub title: String,
373    /// Command identifier.
374    pub command: String,
375    /// Command arguments.
376    #[serde(skip_serializing_if = "Vec::is_empty", default)]
377    pub arguments: Vec<serde_json::Value>,
378}
379
380/// Result of code actions request.
381#[derive(Debug, Clone, Serialize, Deserialize)]
382pub struct CodeActionsResult {
383    /// Available code actions.
384    pub actions: Vec<CodeAction>,
385}
386
387/// A call hierarchy item.
388#[derive(Debug, Clone, Serialize, Deserialize)]
389pub struct CallHierarchyItemResult {
390    /// Name of the symbol.
391    pub name: String,
392    /// LSP numeric symbol kind (e.g. 12 for Function).
393    pub kind: u32,
394    /// More detail for this item.
395    #[serde(skip_serializing_if = "Option::is_none")]
396    pub detail: Option<String>,
397    /// URI of the document.
398    pub uri: String,
399    /// Range of the symbol.
400    pub range: Range,
401    /// Selection range (identifier location).
402    ///
403    /// Serialized as `selectionRange` (camelCase) so that the value returned by
404    /// `prepare_call_hierarchy` round-trips correctly when the MCP client passes
405    /// it back to `get_incoming_calls` / `get_outgoing_calls`, which deserialize
406    /// it as `lsp_types::CallHierarchyItem` (camelCase).
407    #[serde(rename = "selectionRange")]
408    pub selection_range: Range,
409    /// Opaque data to pass to incoming/outgoing calls.
410    #[serde(skip_serializing_if = "Option::is_none")]
411    pub data: Option<serde_json::Value>,
412}
413
414/// Result of call hierarchy prepare request.
415#[derive(Debug, Clone, Serialize, Deserialize)]
416pub struct CallHierarchyPrepareResult {
417    /// List of callable items at the position.
418    pub items: Vec<CallHierarchyItemResult>,
419}
420
421/// An incoming call (caller of the current item).
422#[derive(Debug, Clone, Serialize, Deserialize)]
423pub struct IncomingCall {
424    /// The item that calls the current item.
425    pub from: CallHierarchyItemResult,
426    /// Ranges where the call occurs.
427    pub from_ranges: Vec<Range>,
428}
429
430/// Result of incoming calls request.
431#[derive(Debug, Clone, Serialize, Deserialize)]
432pub struct IncomingCallsResult {
433    /// List of incoming calls.
434    pub calls: Vec<IncomingCall>,
435}
436
437/// An outgoing call (callee from the current item).
438#[derive(Debug, Clone, Serialize, Deserialize)]
439pub struct OutgoingCall {
440    /// The item being called.
441    pub to: CallHierarchyItemResult,
442    /// Ranges where the call occurs.
443    pub from_ranges: Vec<Range>,
444}
445
446/// Result of outgoing calls request.
447#[derive(Debug, Clone, Serialize, Deserialize)]
448pub struct OutgoingCallsResult {
449    /// List of outgoing calls.
450    pub calls: Vec<OutgoingCall>,
451}
452
453/// Result of server logs request.
454#[derive(Debug, Clone, Serialize, Deserialize)]
455pub struct ServerLogsResult {
456    /// List of log entries.
457    pub logs: Vec<crate::bridge::notifications::LogEntry>,
458}
459
460/// Result of server messages request.
461#[derive(Debug, Clone, Serialize, Deserialize)]
462pub struct ServerMessagesResult {
463    /// List of server messages.
464    pub messages: Vec<crate::bridge::notifications::ServerMessage>,
465}
466
467/// A single parameter in a signature.
468#[derive(Debug, Clone, Serialize, Deserialize)]
469pub struct SignatureParameter {
470    /// Label of the parameter.
471    pub label: String,
472    /// Optional documentation for the parameter.
473    #[serde(skip_serializing_if = "Option::is_none")]
474    pub documentation: Option<String>,
475}
476
477/// A single signature overload.
478#[derive(Debug, Clone, Serialize, Deserialize)]
479pub struct SignatureInfo {
480    /// Full label of the signature.
481    pub label: String,
482    /// Optional documentation for the signature.
483    #[serde(skip_serializing_if = "Option::is_none")]
484    pub documentation: Option<String>,
485    /// Parameters of the signature.
486    pub parameters: Vec<SignatureParameter>,
487}
488
489/// Result of a signature help request.
490#[derive(Debug, Clone, Serialize, Deserialize)]
491pub struct SignatureHelpResult {
492    /// Available signatures.
493    pub signatures: Vec<SignatureInfo>,
494    /// Index of the active signature.
495    #[serde(skip_serializing_if = "Option::is_none")]
496    pub active_signature: Option<u32>,
497    /// Index of the active parameter within the active signature.
498    #[serde(skip_serializing_if = "Option::is_none")]
499    pub active_parameter: Option<u32>,
500}
501
502/// Result of a go-to-implementation or go-to-type-definition request.
503#[derive(Debug, Clone, Serialize, Deserialize)]
504pub struct LocationsResult {
505    /// Locations found.
506    pub locations: Vec<Location>,
507}
508
509/// A single inlay hint entry.
510#[derive(Debug, Clone, Serialize, Deserialize)]
511pub struct InlayHintEntry {
512    /// Position of the hint (1-based MCP).
513    pub position: Position2D,
514    /// Label text for the hint.
515    pub label: String,
516    /// Hint kind (1 = Type, 2 = Parameter).
517    #[serde(skip_serializing_if = "Option::is_none")]
518    pub kind: Option<u8>,
519    /// Whether to add a space before the hint.
520    #[serde(skip_serializing_if = "Option::is_none")]
521    pub padding_left: Option<bool>,
522    /// Whether to add a space after the hint.
523    #[serde(skip_serializing_if = "Option::is_none")]
524    pub padding_right: Option<bool>,
525    /// Tooltip text.
526    #[serde(skip_serializing_if = "Option::is_none")]
527    pub tooltip: Option<String>,
528}
529
530/// Result of an inlay hints request.
531#[derive(Debug, Clone, Serialize, Deserialize)]
532pub struct InlayHintsResult {
533    /// List of inlay hints.
534    pub hints: Vec<InlayHintEntry>,
535}
536
537/// Maximum allowed position value for validation.
538const MAX_POSITION_VALUE: u32 = 1_000_000;
539/// Maximum allowed range size in lines.
540const MAX_RANGE_LINES: u32 = 10_000;
541
542impl Translator {
543    /// Validate that a path is within allowed workspace boundaries.
544    ///
545    /// # Errors
546    ///
547    /// Returns `Error::PathOutsideWorkspace` if the path is outside all workspace roots.
548    pub(crate) fn validate_path(&self, path: &Path) -> Result<PathBuf> {
549        let canonical = path.canonicalize().map_err(|e| Error::FileIo {
550            path: path.to_path_buf(),
551            source: e,
552        })?;
553
554        // If no workspace roots configured, allow any path (backward compatibility)
555        if self.workspace_roots.is_empty() {
556            return Ok(canonical);
557        }
558
559        // Check if path is within any workspace root
560        for root in &self.workspace_roots {
561            if let Ok(canonical_root) = root.canonicalize()
562                && canonical.starts_with(&canonical_root)
563            {
564                return Ok(canonical);
565            }
566        }
567
568        Err(Error::PathOutsideWorkspace(path.to_path_buf()))
569    }
570
571    /// Get a cloned LSP client for a file path based on language detection.
572    fn get_client_for_file(&self, path: &Path) -> Result<LspClient> {
573        let language_id = detect_language(path, &self.extension_map);
574        self.lsp_clients.get(&language_id).cloned().ok_or_else(|| {
575            // A configured+applicable language whose server has not registered
576            // yet is still initializing (e.g. a large Unity solution loading via
577            // OmniSharp); tell the caller to wait and retry rather than implying
578            // no server is configured at all.
579            if self.expected_languages.contains(&language_id) {
580                Error::ServerInitializing(language_id)
581            } else {
582                Error::NoServerForLanguage(language_id)
583            }
584        })
585    }
586
587    /// Parse and validate a file URI, returning the validated path.
588    ///
589    /// # Errors
590    ///
591    /// Returns an error if:
592    /// - The URI doesn't have a file:// scheme
593    /// - The path is outside workspace boundaries
594    fn parse_file_uri(&self, uri: &lsp_types::Uri) -> Result<PathBuf> {
595        let uri_str = uri.as_str();
596
597        // Validate file:// scheme
598        if !uri_str.starts_with("file://") {
599            return Err(Error::InvalidToolParams(format!(
600                "Invalid URI scheme, expected file:// but got: {uri_str}"
601            )));
602        }
603
604        // Extract path after file://
605        let path_str = &uri_str["file://".len()..];
606
607        // Handle Windows paths: file:///C:/path -> /C:/path -> C:/path
608        // On Windows, URIs have format file:///C:/path, so we need to strip the leading /
609        #[cfg(windows)]
610        let path_str = if path_str.len() >= 3
611            && path_str.starts_with('/')
612            && path_str.chars().nth(2) == Some(':')
613        {
614            &path_str[1..]
615        } else {
616            path_str
617        };
618
619        let path = PathBuf::from(path_str);
620
621        // Validate path is within workspace
622        self.validate_path(&path)
623    }
624
625    /// Handle hover request.
626    ///
627    /// # Errors
628    ///
629    /// Returns an error if the LSP request fails or the file cannot be opened.
630    pub async fn handle_hover(
631        &mut self,
632        file_path: String,
633        line: u32,
634        character: u32,
635    ) -> Result<HoverResult> {
636        let path = PathBuf::from(&file_path);
637        let validated_path = self.validate_path(&path)?;
638        let client = self.get_client_for_file(&validated_path)?;
639        let uri = self
640            .document_tracker
641            .ensure_open(&validated_path, &client)
642            .await?;
643        let lsp_position = mcp_to_lsp_position(line, character);
644
645        let params = LspHoverParams {
646            text_document_position_params: TextDocumentPositionParams {
647                text_document: TextDocumentIdentifier { uri },
648                position: lsp_position,
649            },
650            work_done_progress_params: WorkDoneProgressParams::default(),
651        };
652
653        let timeout_duration = Duration::from_secs(30);
654        let response: Option<Hover> = client
655            .request("textDocument/hover", params, timeout_duration)
656            .await?;
657
658        let result = match response {
659            Some(hover) => {
660                let contents = extract_hover_contents(hover.contents);
661                let range = hover.range.map(normalize_range);
662                HoverResult { contents, range }
663            }
664            None => HoverResult {
665                contents: "No hover information available".to_string(),
666                range: None,
667            },
668        };
669
670        Ok(result)
671    }
672
673    /// Handle definition 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_definition(
679        &mut self,
680        file_path: String,
681        line: u32,
682        character: u32,
683    ) -> Result<DefinitionResult> {
684        let path = PathBuf::from(&file_path);
685        let validated_path = self.validate_path(&path)?;
686        let client = self.get_client_for_file(&validated_path)?;
687        let uri = self
688            .document_tracker
689            .ensure_open(&validated_path, &client)
690            .await?;
691        let lsp_position = mcp_to_lsp_position(line, character);
692
693        let params = GotoDefinitionParams {
694            text_document_position_params: TextDocumentPositionParams {
695                text_document: TextDocumentIdentifier { uri },
696                position: lsp_position,
697            },
698            work_done_progress_params: WorkDoneProgressParams::default(),
699            partial_result_params: PartialResultParams::default(),
700        };
701
702        let timeout_duration = Duration::from_secs(30);
703        let response: Option<lsp_types::GotoDefinitionResponse> = client
704            .request("textDocument/definition", params, timeout_duration)
705            .await?;
706
707        let locations = match response {
708            Some(lsp_types::GotoDefinitionResponse::Scalar(loc)) => vec![loc],
709            Some(lsp_types::GotoDefinitionResponse::Array(locs)) => locs,
710            Some(lsp_types::GotoDefinitionResponse::Link(links)) => links
711                .into_iter()
712                .map(|link| lsp_types::Location {
713                    uri: link.target_uri,
714                    range: link.target_selection_range,
715                })
716                .collect(),
717            None => vec![],
718        };
719
720        let result = DefinitionResult {
721            locations: locations
722                .into_iter()
723                .map(|loc| Location {
724                    uri: loc.uri.to_string(),
725                    range: normalize_range(loc.range),
726                })
727                .collect(),
728        };
729
730        Ok(result)
731    }
732
733    /// Handle references request.
734    ///
735    /// # Errors
736    ///
737    /// Returns an error if the LSP request fails or the file cannot be opened.
738    pub async fn handle_references(
739        &mut self,
740        file_path: String,
741        line: u32,
742        character: u32,
743        include_declaration: bool,
744    ) -> Result<ReferencesResult> {
745        let path = PathBuf::from(&file_path);
746        let validated_path = self.validate_path(&path)?;
747        let client = self.get_client_for_file(&validated_path)?;
748        let uri = self
749            .document_tracker
750            .ensure_open(&validated_path, &client)
751            .await?;
752        let lsp_position = mcp_to_lsp_position(line, character);
753
754        let params = ReferenceParams {
755            text_document_position: TextDocumentPositionParams {
756                text_document: TextDocumentIdentifier { uri },
757                position: lsp_position,
758            },
759            work_done_progress_params: WorkDoneProgressParams::default(),
760            partial_result_params: PartialResultParams::default(),
761            context: ReferenceContext {
762                include_declaration,
763            },
764        };
765
766        let timeout_duration = Duration::from_secs(30);
767        let response: Option<Vec<lsp_types::Location>> = client
768            .request("textDocument/references", params, timeout_duration)
769            .await?;
770
771        let locations = response.unwrap_or_default();
772
773        let result = ReferencesResult {
774            locations: locations
775                .into_iter()
776                .map(|loc| Location {
777                    uri: loc.uri.to_string(),
778                    range: normalize_range(loc.range),
779                })
780                .collect(),
781        };
782
783        Ok(result)
784    }
785
786    /// Handle diagnostics request.
787    ///
788    /// # Errors
789    ///
790    /// Returns an error if the LSP request fails or the file cannot be opened.
791    pub async fn handle_diagnostics(&mut self, file_path: String) -> Result<DiagnosticsResult> {
792        let path = PathBuf::from(&file_path);
793        let validated_path = self.validate_path(&path)?;
794        let client = self.get_client_for_file(&validated_path)?;
795        let uri = self
796            .document_tracker
797            .ensure_open(&validated_path, &client)
798            .await?;
799
800        let params = diagnostic_request_params(TextDocumentIdentifier { uri });
801
802        let timeout_duration = Duration::from_secs(30);
803        let response: lsp_types::DocumentDiagnosticReportResult = client
804            .request("textDocument/diagnostic", params, timeout_duration)
805            .await?;
806
807        let diagnostics = match response {
808            lsp_types::DocumentDiagnosticReportResult::Report(report) => match report {
809                lsp_types::DocumentDiagnosticReport::Full(full) => {
810                    full.full_document_diagnostic_report.items
811                }
812                lsp_types::DocumentDiagnosticReport::Unchanged(_) => vec![],
813            },
814            lsp_types::DocumentDiagnosticReportResult::Partial(_) => vec![],
815        };
816
817        let result = DiagnosticsResult {
818            diagnostics: diagnostics
819                .into_iter()
820                .map(|diag| Diagnostic {
821                    range: normalize_range(diag.range),
822                    severity: match diag.severity {
823                        Some(lsp_types::DiagnosticSeverity::ERROR) => DiagnosticSeverity::Error,
824                        Some(lsp_types::DiagnosticSeverity::WARNING) => DiagnosticSeverity::Warning,
825                        Some(lsp_types::DiagnosticSeverity::INFORMATION) => {
826                            DiagnosticSeverity::Information
827                        }
828                        Some(lsp_types::DiagnosticSeverity::HINT) => DiagnosticSeverity::Hint,
829                        _ => DiagnosticSeverity::Information,
830                    },
831                    message: diag.message,
832                    code: diag.code.map(|c| match c {
833                        lsp_types::NumberOrString::Number(n) => n.to_string(),
834                        lsp_types::NumberOrString::String(s) => s,
835                    }),
836                })
837                .collect(),
838        };
839
840        Ok(result)
841    }
842
843    /// Handle rename request.
844    ///
845    /// # Errors
846    ///
847    /// Returns an error if the LSP request fails or the file cannot be opened.
848    pub async fn handle_rename(
849        &mut self,
850        file_path: String,
851        line: u32,
852        character: u32,
853        new_name: String,
854    ) -> Result<RenameResult> {
855        let path = PathBuf::from(&file_path);
856        let validated_path = self.validate_path(&path)?;
857        let client = self.get_client_for_file(&validated_path)?;
858        let uri = self
859            .document_tracker
860            .ensure_open(&validated_path, &client)
861            .await?;
862        let lsp_position = mcp_to_lsp_position(line, character);
863
864        let params = LspRenameParams {
865            text_document_position: TextDocumentPositionParams {
866                text_document: TextDocumentIdentifier { uri },
867                position: lsp_position,
868            },
869            new_name,
870            work_done_progress_params: WorkDoneProgressParams::default(),
871        };
872
873        let timeout_duration = Duration::from_secs(30);
874        let response: Option<WorkspaceEdit> = client
875            .request("textDocument/rename", params, timeout_duration)
876            .await?;
877
878        let changes = if let Some(edit) = response {
879            let mut result_changes = Vec::new();
880
881            // Prefer the legacy `changes` map (HashMap<Uri, Vec<TextEdit>>).
882            if let Some(changes_map) = edit.changes {
883                for (uri, edits) in changes_map {
884                    result_changes.push(DocumentChanges {
885                        uri: uri.to_string(),
886                        edits: edits
887                            .into_iter()
888                            .map(|e| TextEdit {
889                                range: normalize_range(e.range),
890                                new_text: e.new_text,
891                            })
892                            .collect(),
893                    });
894                }
895            }
896
897            // Also handle `documentChanges` (array format returned by rust-analyzer).
898            if result_changes.is_empty() {
899                let text_doc_edits = match edit.document_changes {
900                    Some(lsp_types::DocumentChanges::Edits(edits)) => edits,
901                    Some(lsp_types::DocumentChanges::Operations(ops)) => ops
902                        .into_iter()
903                        .filter_map(|op| match op {
904                            lsp_types::DocumentChangeOperation::Edit(e) => Some(e),
905                            lsp_types::DocumentChangeOperation::Op(_) => None,
906                        })
907                        .collect(),
908                    None => vec![],
909                };
910                for tde in text_doc_edits {
911                    result_changes.push(DocumentChanges {
912                        uri: tde.text_document.uri.to_string(),
913                        edits: tde
914                            .edits
915                            .into_iter()
916                            .map(|one_of| match one_of {
917                                lsp_types::OneOf::Left(te) => TextEdit {
918                                    range: normalize_range(te.range),
919                                    new_text: te.new_text,
920                                },
921                                lsp_types::OneOf::Right(ate) => TextEdit {
922                                    range: normalize_range(ate.text_edit.range),
923                                    new_text: ate.text_edit.new_text,
924                                },
925                            })
926                            .collect(),
927                    });
928                }
929            }
930
931            result_changes
932        } else {
933            vec![]
934        };
935
936        Ok(RenameResult { changes })
937    }
938
939    /// Handle completions request.
940    ///
941    /// # Errors
942    ///
943    /// Returns an error if the LSP request fails or the file cannot be opened.
944    pub async fn handle_completions(
945        &mut self,
946        file_path: String,
947        line: u32,
948        character: u32,
949        trigger: Option<String>,
950    ) -> Result<CompletionsResult> {
951        let path = PathBuf::from(&file_path);
952        let validated_path = self.validate_path(&path)?;
953        let client = self.get_client_for_file(&validated_path)?;
954        let uri = self
955            .document_tracker
956            .ensure_open(&validated_path, &client)
957            .await?;
958        let lsp_position = mcp_to_lsp_position(line, character);
959
960        let context = trigger.map(|trigger_char| lsp_types::CompletionContext {
961            trigger_kind: CompletionTriggerKind::TRIGGER_CHARACTER,
962            trigger_character: Some(trigger_char),
963        });
964
965        let params = CompletionParams {
966            text_document_position: TextDocumentPositionParams {
967                text_document: TextDocumentIdentifier { uri },
968                position: lsp_position,
969            },
970            work_done_progress_params: WorkDoneProgressParams::default(),
971            partial_result_params: PartialResultParams::default(),
972            context,
973        };
974
975        let timeout_duration = Duration::from_secs(10);
976        let response: Option<lsp_types::CompletionResponse> = client
977            .request("textDocument/completion", params, timeout_duration)
978            .await?;
979
980        let items = match response {
981            Some(lsp_types::CompletionResponse::Array(items)) => items,
982            Some(lsp_types::CompletionResponse::List(list)) => list.items,
983            None => vec![],
984        };
985
986        let result = CompletionsResult {
987            items: items
988                .into_iter()
989                .map(|item| Completion {
990                    label: item.label,
991                    kind: item.kind.map(|k| format!("{k:?}")),
992                    detail: item.detail,
993                    documentation: item.documentation.map(|doc| match doc {
994                        lsp_types::Documentation::String(s) => s,
995                        lsp_types::Documentation::MarkupContent(m) => m.value,
996                    }),
997                })
998                .collect(),
999        };
1000
1001        Ok(result)
1002    }
1003
1004    /// Handle document symbols request.
1005    ///
1006    /// # Errors
1007    ///
1008    /// Returns an error if the LSP request fails or the file cannot be opened.
1009    pub async fn handle_document_symbols(
1010        &mut self,
1011        file_path: String,
1012    ) -> Result<DocumentSymbolsResult> {
1013        let path = PathBuf::from(&file_path);
1014        let validated_path = self.validate_path(&path)?;
1015        let client = self.get_client_for_file(&validated_path)?;
1016        let uri = self
1017            .document_tracker
1018            .ensure_open(&validated_path, &client)
1019            .await?;
1020
1021        let params = DocumentSymbolParams {
1022            text_document: TextDocumentIdentifier { uri },
1023            work_done_progress_params: WorkDoneProgressParams::default(),
1024            partial_result_params: PartialResultParams::default(),
1025        };
1026
1027        let timeout_duration = Duration::from_secs(30);
1028        let response: Option<lsp_types::DocumentSymbolResponse> = client
1029            .request("textDocument/documentSymbol", params, timeout_duration)
1030            .await?;
1031
1032        let symbols = match response {
1033            Some(lsp_types::DocumentSymbolResponse::Flat(symbols)) => symbols
1034                .into_iter()
1035                .map(|sym| Symbol {
1036                    name: sym.name,
1037                    kind: format!("{:?}", sym.kind),
1038                    range: normalize_range(sym.location.range),
1039                    selection_range: normalize_range(sym.location.range),
1040                    children: None,
1041                })
1042                .collect(),
1043            Some(lsp_types::DocumentSymbolResponse::Nested(symbols)) => {
1044                symbols.into_iter().map(convert_document_symbol).collect()
1045            }
1046            None => vec![],
1047        };
1048
1049        Ok(DocumentSymbolsResult { symbols })
1050    }
1051
1052    /// Handle format document request.
1053    ///
1054    /// # Errors
1055    ///
1056    /// Returns an error if the LSP request fails or the file cannot be opened.
1057    pub async fn handle_format_document(
1058        &mut self,
1059        file_path: String,
1060        tab_size: u32,
1061        insert_spaces: bool,
1062    ) -> Result<FormatDocumentResult> {
1063        let path = PathBuf::from(&file_path);
1064        let validated_path = self.validate_path(&path)?;
1065        let client = self.get_client_for_file(&validated_path)?;
1066        let uri = self
1067            .document_tracker
1068            .ensure_open(&validated_path, &client)
1069            .await?;
1070
1071        let params = DocumentFormattingParams {
1072            text_document: TextDocumentIdentifier { uri },
1073            options: FormattingOptions {
1074                tab_size,
1075                insert_spaces,
1076                ..Default::default()
1077            },
1078            work_done_progress_params: WorkDoneProgressParams::default(),
1079        };
1080
1081        let timeout_duration = Duration::from_secs(30);
1082        let response: Option<Vec<lsp_types::TextEdit>> = client
1083            .request("textDocument/formatting", params, timeout_duration)
1084            .await?;
1085
1086        let edits = response.unwrap_or_default();
1087
1088        let result = FormatDocumentResult {
1089            edits: edits
1090                .into_iter()
1091                .map(|edit| TextEdit {
1092                    range: normalize_range(edit.range),
1093                    new_text: edit.new_text,
1094                })
1095                .collect(),
1096        };
1097
1098        Ok(result)
1099    }
1100
1101    /// Handle workspace symbol search.
1102    ///
1103    /// # Errors
1104    ///
1105    /// Returns an error if the LSP request fails or no server is configured.
1106    pub async fn handle_workspace_symbol(
1107        &mut self,
1108        query: String,
1109        kind_filter: Option<String>,
1110        limit: u32,
1111    ) -> Result<WorkspaceSymbolResult> {
1112        const MAX_QUERY_LENGTH: usize = 1000;
1113        const VALID_SYMBOL_KINDS: &[&str] = &[
1114            "File",
1115            "Module",
1116            "Namespace",
1117            "Package",
1118            "Class",
1119            "Method",
1120            "Property",
1121            "Field",
1122            "Constructor",
1123            "Enum",
1124            "Interface",
1125            "Function",
1126            "Variable",
1127            "Constant",
1128            "String",
1129            "Number",
1130            "Boolean",
1131            "Array",
1132            "Object",
1133            "Key",
1134            "Null",
1135            "EnumMember",
1136            "Struct",
1137            "Event",
1138            "Operator",
1139            "TypeParameter",
1140        ];
1141
1142        // Validate query length
1143        if query.len() > MAX_QUERY_LENGTH {
1144            return Err(Error::InvalidToolParams(format!(
1145                "Query too long: {} chars (max {MAX_QUERY_LENGTH})",
1146                query.len()
1147            )));
1148        }
1149
1150        // Validate kind filter
1151        if let Some(ref kind) = kind_filter
1152            && !VALID_SYMBOL_KINDS
1153                .iter()
1154                .any(|k| k.eq_ignore_ascii_case(kind))
1155        {
1156            return Err(Error::InvalidToolParams(format!(
1157                "Invalid kind_filter: '{kind}'. Valid values: {VALID_SYMBOL_KINDS:?}"
1158            )));
1159        }
1160
1161        // Workspace search requires at least one LSP client. If none are
1162        // registered yet but a configured server is still initializing, tell the
1163        // caller to wait and retry rather than implying nothing is configured.
1164        let client = self.lsp_clients.values().next().cloned().ok_or_else(|| {
1165            self.expected_languages
1166                .iter()
1167                .next()
1168                .map_or(Error::NoServerConfigured, |lang| {
1169                    Error::ServerInitializing(lang.clone())
1170                })
1171        })?;
1172
1173        let params = LspWorkspaceSymbolParams {
1174            query,
1175            work_done_progress_params: WorkDoneProgressParams::default(),
1176            partial_result_params: PartialResultParams::default(),
1177        };
1178
1179        let timeout_duration = Duration::from_secs(30);
1180        let response: Option<Vec<lsp_types::SymbolInformation>> = client
1181            .request("workspace/symbol", params, timeout_duration)
1182            .await?;
1183
1184        let mut symbols: Vec<WorkspaceSymbol> = response
1185            .unwrap_or_default()
1186            .into_iter()
1187            .map(|sym| WorkspaceSymbol {
1188                name: sym.name,
1189                kind: format!("{:?}", sym.kind),
1190                location: Location {
1191                    uri: sym.location.uri.to_string(),
1192                    range: normalize_range(sym.location.range),
1193                },
1194                container_name: sym.container_name,
1195            })
1196            .collect();
1197
1198        // Apply kind filter if specified
1199        if let Some(kind) = kind_filter {
1200            symbols.retain(|s| s.kind.eq_ignore_ascii_case(&kind));
1201        }
1202
1203        // Limit results
1204        symbols.truncate(limit as usize);
1205
1206        Ok(WorkspaceSymbolResult { symbols })
1207    }
1208
1209    /// Handle code actions request.
1210    ///
1211    /// # Errors
1212    ///
1213    /// Returns an error if the LSP request fails or the file cannot be opened.
1214    pub async fn handle_code_actions(
1215        &mut self,
1216        file_path: String,
1217        start_line: u32,
1218        start_character: u32,
1219        end_line: u32,
1220        end_character: u32,
1221        kind_filter: Option<String>,
1222    ) -> Result<CodeActionsResult> {
1223        validate_code_action_params(
1224            start_line,
1225            start_character,
1226            end_line,
1227            end_character,
1228            kind_filter.as_deref(),
1229        )?;
1230
1231        let path = PathBuf::from(&file_path);
1232        let validated_path = self.validate_path(&path)?;
1233        let client = self.get_client_for_file(&validated_path)?;
1234        let uri = self
1235            .document_tracker
1236            .ensure_open(&validated_path, &client)
1237            .await?;
1238
1239        let range = lsp_types::Range {
1240            start: mcp_to_lsp_position(start_line, start_character),
1241            end: mcp_to_lsp_position(end_line, end_character),
1242        };
1243
1244        // Build context with optional kind filter
1245        let only = kind_filter.map(|k| vec![lsp_types::CodeActionKind::from(k)]);
1246
1247        // Pass empty diagnostics context — rust-analyzer generates code actions
1248        // based on cursor position and its internal analysis state, not on the
1249        // passed diagnostics.  Passing stale cached diagnostics (which may lack
1250        // the internal `data` field ra uses for fix mapping) suppresses results.
1251        let context_diagnostics: Vec<lsp_types::Diagnostic> = vec![];
1252
1253        let params = lsp_types::CodeActionParams {
1254            text_document: TextDocumentIdentifier { uri },
1255            range,
1256            context: lsp_types::CodeActionContext {
1257                diagnostics: context_diagnostics,
1258                only,
1259                trigger_kind: Some(lsp_types::CodeActionTriggerKind::INVOKED),
1260            },
1261            work_done_progress_params: WorkDoneProgressParams::default(),
1262            partial_result_params: PartialResultParams::default(),
1263        };
1264
1265        let timeout_duration = Duration::from_secs(30);
1266        let response: Option<lsp_types::CodeActionResponse> = client
1267            .request("textDocument/codeAction", params, timeout_duration)
1268            .await?;
1269        let response_vec = response.unwrap_or_default();
1270        let mut actions = Vec::with_capacity(response_vec.len());
1271
1272        for action_or_command in response_vec {
1273            let action = match action_or_command {
1274                lsp_types::CodeActionOrCommand::CodeAction(action) => convert_code_action(action),
1275                lsp_types::CodeActionOrCommand::Command(cmd) => {
1276                    let arguments = cmd.arguments.unwrap_or_else(Vec::new);
1277                    CodeAction {
1278                        title: cmd.title.clone(),
1279                        kind: None,
1280                        diagnostics: Vec::new(),
1281                        edit: None,
1282                        command: Some(CommandDescription {
1283                            title: cmd.title,
1284                            command: cmd.command,
1285                            arguments,
1286                        }),
1287                        is_preferred: false,
1288                    }
1289                }
1290            };
1291            actions.push(action);
1292        }
1293
1294        Ok(CodeActionsResult { actions })
1295    }
1296
1297    /// Handle call hierarchy prepare request.
1298    ///
1299    /// # Errors
1300    ///
1301    /// Returns an error if the LSP request fails or the file cannot be opened.
1302    pub async fn handle_call_hierarchy_prepare(
1303        &mut self,
1304        file_path: String,
1305        line: u32,
1306        character: u32,
1307    ) -> Result<CallHierarchyPrepareResult> {
1308        // Validate position bounds
1309        if line < 1 || character < 1 {
1310            return Err(Error::InvalidToolParams(
1311                "Line and character positions must be >= 1".to_string(),
1312            ));
1313        }
1314
1315        if line > MAX_POSITION_VALUE || character > MAX_POSITION_VALUE {
1316            return Err(Error::InvalidToolParams(format!(
1317                "Position values must be <= {MAX_POSITION_VALUE}"
1318            )));
1319        }
1320
1321        let path = PathBuf::from(&file_path);
1322        let validated_path = self.validate_path(&path)?;
1323        let client = self.get_client_for_file(&validated_path)?;
1324        let uri = self
1325            .document_tracker
1326            .ensure_open(&validated_path, &client)
1327            .await?;
1328        let lsp_position = mcp_to_lsp_position(line, character);
1329
1330        let params = LspCallHierarchyPrepareParams {
1331            text_document_position_params: TextDocumentPositionParams {
1332                text_document: TextDocumentIdentifier { uri },
1333                position: lsp_position,
1334            },
1335            work_done_progress_params: WorkDoneProgressParams::default(),
1336        };
1337
1338        let timeout_duration = Duration::from_secs(30);
1339        let response: Option<Vec<CallHierarchyItem>> = client
1340            .request(
1341                "textDocument/prepareCallHierarchy",
1342                params,
1343                timeout_duration,
1344            )
1345            .await?;
1346
1347        // Pre-allocate and build result
1348        let lsp_items = response.unwrap_or_default();
1349        let mut items = Vec::with_capacity(lsp_items.len());
1350        for item in lsp_items {
1351            items.push(convert_call_hierarchy_item(item));
1352        }
1353
1354        Ok(CallHierarchyPrepareResult { items })
1355    }
1356
1357    /// Handle incoming calls request.
1358    ///
1359    /// # Errors
1360    ///
1361    /// Returns an error if the LSP request fails or the item is invalid.
1362    pub async fn handle_incoming_calls(
1363        &mut self,
1364        item: serde_json::Value,
1365    ) -> Result<IncomingCallsResult> {
1366        // Deserialize as our own type (1-based coords) then convert to LSP (0-based).
1367        let lsp_item = mcp_item_to_lsp(item)?;
1368
1369        // Parse and validate the URI
1370        let path = self.parse_file_uri(&lsp_item.uri)?;
1371        let client = self.get_client_for_file(&path)?;
1372
1373        let params = CallHierarchyIncomingCallsParams {
1374            item: lsp_item,
1375            work_done_progress_params: WorkDoneProgressParams::default(),
1376            partial_result_params: PartialResultParams::default(),
1377        };
1378
1379        let timeout_duration = Duration::from_secs(30);
1380        let response: Option<Vec<CallHierarchyIncomingCall>> = client
1381            .request("callHierarchy/incomingCalls", params, timeout_duration)
1382            .await?;
1383
1384        // Pre-allocate and build result
1385        let lsp_calls = response.unwrap_or_default();
1386        let mut calls = Vec::with_capacity(lsp_calls.len());
1387
1388        for call in lsp_calls {
1389            let from_ranges = {
1390                let mut ranges = Vec::with_capacity(call.from_ranges.len());
1391                for range in call.from_ranges {
1392                    ranges.push(normalize_range(range));
1393                }
1394                ranges
1395            };
1396
1397            calls.push(IncomingCall {
1398                from: convert_call_hierarchy_item(call.from),
1399                from_ranges,
1400            });
1401        }
1402
1403        Ok(IncomingCallsResult { calls })
1404    }
1405
1406    /// Handle outgoing calls request.
1407    ///
1408    /// # Errors
1409    ///
1410    /// Returns an error if the LSP request fails or the item is invalid.
1411    pub async fn handle_outgoing_calls(
1412        &mut self,
1413        item: serde_json::Value,
1414    ) -> Result<OutgoingCallsResult> {
1415        // Deserialize as our own type (1-based coords) then convert to LSP (0-based).
1416        let lsp_item = mcp_item_to_lsp(item)?;
1417
1418        // Parse and validate the URI
1419        let path = self.parse_file_uri(&lsp_item.uri)?;
1420        let client = self.get_client_for_file(&path)?;
1421
1422        let params = CallHierarchyOutgoingCallsParams {
1423            item: lsp_item,
1424            work_done_progress_params: WorkDoneProgressParams::default(),
1425            partial_result_params: PartialResultParams::default(),
1426        };
1427
1428        let timeout_duration = Duration::from_secs(30);
1429        let response: Option<Vec<CallHierarchyOutgoingCall>> = client
1430            .request("callHierarchy/outgoingCalls", params, timeout_duration)
1431            .await?;
1432
1433        // Pre-allocate and build result
1434        let lsp_calls = response.unwrap_or_default();
1435        let mut calls = Vec::with_capacity(lsp_calls.len());
1436
1437        for call in lsp_calls {
1438            let from_ranges = {
1439                let mut ranges = Vec::with_capacity(call.from_ranges.len());
1440                for range in call.from_ranges {
1441                    ranges.push(normalize_range(range));
1442                }
1443                ranges
1444            };
1445
1446            calls.push(OutgoingCall {
1447                to: convert_call_hierarchy_item(call.to),
1448                from_ranges,
1449            });
1450        }
1451
1452        Ok(OutgoingCallsResult { calls })
1453    }
1454
1455    /// Handle cached diagnostics request.
1456    ///
1457    /// # Errors
1458    ///
1459    /// Returns an error if the path is invalid or outside workspace boundaries.
1460    pub fn handle_cached_diagnostics(&mut self, file_path: &str) -> Result<DiagnosticsResult> {
1461        let path = PathBuf::from(file_path);
1462        let validated_path = self.validate_path(&path)?;
1463
1464        // Use path_to_uri (strips \\?\ on Windows) so the key matches what
1465        // rust-analyzer stores in publishDiagnostics notifications.
1466        let uri = path_to_uri(&validated_path).to_string();
1467
1468        let diagnostics =
1469            self.notification_cache
1470                .get_diagnostics(&uri)
1471                .map_or_else(Vec::new, |diag_info| {
1472                    diag_info
1473                        .diagnostics
1474                        .iter()
1475                        .map(|diag| Diagnostic {
1476                            range: normalize_range(diag.range),
1477                            severity: match diag.severity {
1478                                Some(lsp_types::DiagnosticSeverity::ERROR) => {
1479                                    DiagnosticSeverity::Error
1480                                }
1481                                Some(lsp_types::DiagnosticSeverity::WARNING) => {
1482                                    DiagnosticSeverity::Warning
1483                                }
1484                                Some(lsp_types::DiagnosticSeverity::INFORMATION) => {
1485                                    DiagnosticSeverity::Information
1486                                }
1487                                Some(lsp_types::DiagnosticSeverity::HINT) => {
1488                                    DiagnosticSeverity::Hint
1489                                }
1490                                _ => DiagnosticSeverity::Information,
1491                            },
1492                            message: diag.message.clone(),
1493                            code: diag.code.as_ref().map(|c| match c {
1494                                lsp_types::NumberOrString::Number(n) => n.to_string(),
1495                                lsp_types::NumberOrString::String(s) => s.clone(),
1496                            }),
1497                        })
1498                        .collect()
1499                });
1500
1501        Ok(DiagnosticsResult { diagnostics })
1502    }
1503
1504    /// Handle server logs request.
1505    ///
1506    /// # Errors
1507    ///
1508    /// Returns an error if the `min_level` parameter is invalid.
1509    pub fn handle_server_logs(
1510        &mut self,
1511        limit: usize,
1512        min_level: Option<String>,
1513    ) -> Result<ServerLogsResult> {
1514        use crate::bridge::notifications::LogLevel;
1515
1516        let min_level_filter = if let Some(level_str) = min_level {
1517            let level = match level_str.to_lowercase().as_str() {
1518                "error" => LogLevel::Error,
1519                "warning" => LogLevel::Warning,
1520                "info" => LogLevel::Info,
1521                "debug" => LogLevel::Debug,
1522                _ => {
1523                    return Err(Error::InvalidToolParams(format!(
1524                        "Invalid min_level: '{level_str}'. Valid values: error, warning, info, debug"
1525                    )));
1526                }
1527            };
1528            Some(level)
1529        } else {
1530            None
1531        };
1532
1533        let all_logs = self.notification_cache.get_logs();
1534
1535        let logs: Vec<_> = all_logs
1536            .iter()
1537            .filter(|log| {
1538                min_level_filter.is_none_or(|min| match min {
1539                    LogLevel::Error => matches!(log.level, LogLevel::Error),
1540                    LogLevel::Warning => matches!(log.level, LogLevel::Error | LogLevel::Warning),
1541                    LogLevel::Info => !matches!(log.level, LogLevel::Debug),
1542                    LogLevel::Debug => true,
1543                })
1544            })
1545            .take(limit)
1546            .cloned()
1547            .collect();
1548
1549        Ok(ServerLogsResult { logs })
1550    }
1551
1552    /// Handle server messages request.
1553    ///
1554    /// # Errors
1555    ///
1556    /// This method does not return errors.
1557    pub fn handle_server_messages(&mut self, limit: usize) -> Result<ServerMessagesResult> {
1558        let all_messages = self.notification_cache.get_messages();
1559        let messages: Vec<_> = all_messages.iter().take(limit).cloned().collect();
1560        Ok(ServerMessagesResult { messages })
1561    }
1562
1563    /// Handle signature help request (`textDocument/signatureHelp`).
1564    ///
1565    /// Returns parameter signatures and documentation while typing a function call.
1566    /// `context` is omitted (None) — the server infers trigger state from position.
1567    ///
1568    /// # Errors
1569    ///
1570    /// Returns an error if the LSP request fails or the file cannot be opened.
1571    pub async fn handle_signature_help(
1572        &mut self,
1573        file_path: String,
1574        line: u32,
1575        character: u32,
1576    ) -> Result<SignatureHelpResult> {
1577        let path = PathBuf::from(&file_path);
1578        let validated_path = self.validate_path(&path)?;
1579        let client = self.get_client_for_file(&validated_path)?;
1580        let uri = self
1581            .document_tracker
1582            .ensure_open(&validated_path, &client)
1583            .await?;
1584        let lsp_position = mcp_to_lsp_position(line, character);
1585
1586        let params = LspSignatureHelpParams {
1587            text_document_position_params: TextDocumentPositionParams {
1588                text_document: TextDocumentIdentifier { uri },
1589                position: lsp_position,
1590            },
1591            work_done_progress_params: WorkDoneProgressParams::default(),
1592            context: None,
1593        };
1594
1595        let timeout_duration = Duration::from_secs(30);
1596        let response: Option<lsp_types::SignatureHelp> = client
1597            .request("textDocument/signatureHelp", params, timeout_duration)
1598            .await?;
1599
1600        let result = match response {
1601            Some(sig_help) => SignatureHelpResult {
1602                signatures: sig_help
1603                    .signatures
1604                    .into_iter()
1605                    .map(|sig| SignatureInfo {
1606                        label: sig.label,
1607                        documentation: sig.documentation.map(extract_documentation),
1608                        parameters: sig
1609                            .parameters
1610                            .unwrap_or_default()
1611                            .into_iter()
1612                            .map(|p| SignatureParameter {
1613                                label: match p.label {
1614                                    lsp_types::ParameterLabel::Simple(s) => s,
1615                                    lsp_types::ParameterLabel::LabelOffsets([start, end]) => {
1616                                        format!("[{start},{end}]")
1617                                    }
1618                                },
1619                                documentation: p.documentation.map(extract_documentation),
1620                            })
1621                            .collect(),
1622                    })
1623                    .collect(),
1624                active_signature: sig_help.active_signature,
1625                active_parameter: sig_help.active_parameter,
1626            },
1627            None => SignatureHelpResult {
1628                signatures: vec![],
1629                active_signature: None,
1630                active_parameter: None,
1631            },
1632        };
1633
1634        Ok(result)
1635    }
1636
1637    /// Handle go-to-implementation request (`textDocument/implementation`).
1638    ///
1639    /// Returns the locations of trait method or interface member implementations.
1640    ///
1641    /// # Errors
1642    ///
1643    /// Returns an error if the LSP request fails or the file cannot be opened.
1644    pub async fn handle_implementation(
1645        &mut self,
1646        file_path: String,
1647        line: u32,
1648        character: u32,
1649    ) -> Result<LocationsResult> {
1650        let path = PathBuf::from(&file_path);
1651        let validated_path = self.validate_path(&path)?;
1652        let client = self.get_client_for_file(&validated_path)?;
1653        let uri = self
1654            .document_tracker
1655            .ensure_open(&validated_path, &client)
1656            .await?;
1657        let lsp_position = mcp_to_lsp_position(line, character);
1658
1659        let params = GotoDefinitionParams {
1660            text_document_position_params: TextDocumentPositionParams {
1661                text_document: TextDocumentIdentifier { uri },
1662                position: lsp_position,
1663            },
1664            work_done_progress_params: WorkDoneProgressParams::default(),
1665            partial_result_params: PartialResultParams::default(),
1666        };
1667
1668        let timeout_duration = Duration::from_secs(30);
1669        let response: Option<lsp_types::GotoDefinitionResponse> = client
1670            .request("textDocument/implementation", params, timeout_duration)
1671            .await?;
1672
1673        Ok(LocationsResult {
1674            locations: goto_response_to_locations(response),
1675        })
1676    }
1677
1678    /// Handle go-to-type-definition request (`textDocument/typeDefinition`).
1679    ///
1680    /// Returns the type definition location of the expression at position. Distinct
1681    /// from go-to-definition for variable bindings where definition and type differ.
1682    ///
1683    /// # Errors
1684    ///
1685    /// Returns an error if the LSP request fails or the file cannot be opened.
1686    pub async fn handle_type_definition(
1687        &mut self,
1688        file_path: String,
1689        line: u32,
1690        character: u32,
1691    ) -> Result<LocationsResult> {
1692        let path = PathBuf::from(&file_path);
1693        let validated_path = self.validate_path(&path)?;
1694        let client = self.get_client_for_file(&validated_path)?;
1695        let uri = self
1696            .document_tracker
1697            .ensure_open(&validated_path, &client)
1698            .await?;
1699        let lsp_position = mcp_to_lsp_position(line, character);
1700
1701        let params = GotoDefinitionParams {
1702            text_document_position_params: TextDocumentPositionParams {
1703                text_document: TextDocumentIdentifier { uri },
1704                position: lsp_position,
1705            },
1706            work_done_progress_params: WorkDoneProgressParams::default(),
1707            partial_result_params: PartialResultParams::default(),
1708        };
1709
1710        let timeout_duration = Duration::from_secs(30);
1711        let response: Option<lsp_types::GotoDefinitionResponse> = client
1712            .request("textDocument/typeDefinition", params, timeout_duration)
1713            .await?;
1714
1715        Ok(LocationsResult {
1716            locations: goto_response_to_locations(response),
1717        })
1718    }
1719
1720    /// Handle inlay hints request (`textDocument/inlayHint`).
1721    ///
1722    /// Returns inferred type and parameter annotations the editor would render inline.
1723    /// Output positions are in MCP 1-based form.
1724    ///
1725    /// # Errors
1726    ///
1727    /// Returns an error if the LSP request fails or the file cannot be opened.
1728    pub async fn handle_inlay_hints(
1729        &mut self,
1730        file_path: String,
1731        start_line: u32,
1732        start_character: u32,
1733        end_line: u32,
1734        end_character: u32,
1735    ) -> Result<InlayHintsResult> {
1736        use crate::bridge::encoding::lsp_to_mcp_position;
1737
1738        let path = PathBuf::from(&file_path);
1739        let validated_path = self.validate_path(&path)?;
1740        let client = self.get_client_for_file(&validated_path)?;
1741        let uri = self
1742            .document_tracker
1743            .ensure_open(&validated_path, &client)
1744            .await?;
1745
1746        let lsp_start = mcp_to_lsp_position(start_line, start_character);
1747        let lsp_end = mcp_to_lsp_position(end_line, end_character);
1748
1749        let params = InlayHintParams {
1750            text_document: TextDocumentIdentifier { uri },
1751            range: lsp_types::Range {
1752                start: lsp_start,
1753                end: lsp_end,
1754            },
1755            work_done_progress_params: WorkDoneProgressParams::default(),
1756        };
1757
1758        let timeout_duration = Duration::from_secs(30);
1759        let response: Option<Vec<lsp_types::InlayHint>> = client
1760            .request("textDocument/inlayHint", params, timeout_duration)
1761            .await?;
1762
1763        let hints = response
1764            .unwrap_or_default()
1765            .into_iter()
1766            .map(|hint| {
1767                let (mcp_line, mcp_character) = lsp_to_mcp_position(hint.position);
1768                let label = match hint.label {
1769                    InlayHintLabel::String(s) => s,
1770                    InlayHintLabel::LabelParts(parts) => parts
1771                        .into_iter()
1772                        .map(|p| p.value)
1773                        .collect::<Vec<_>>()
1774                        .concat(),
1775                };
1776                let tooltip = hint.tooltip.map(|t| match t {
1777                    lsp_types::InlayHintTooltip::String(s) => s,
1778                    lsp_types::InlayHintTooltip::MarkupContent(m) => m.value,
1779                });
1780                InlayHintEntry {
1781                    position: Position2D {
1782                        line: mcp_line,
1783                        character: mcp_character,
1784                    },
1785                    label,
1786                    kind: hint.kind.and_then(|k| {
1787                        serde_json::to_value(k)
1788                            .ok()
1789                            .and_then(|v| v.as_i64())
1790                            .and_then(|n| u8::try_from(n).ok())
1791                    }),
1792                    padding_left: hint.padding_left,
1793                    padding_right: hint.padding_right,
1794                    tooltip,
1795                }
1796            })
1797            .collect();
1798
1799        Ok(InlayHintsResult { hints })
1800    }
1801}
1802
1803/// Extract hover contents as markdown string.
1804/// Convert LSP `Documentation` to a plain string.
1805fn extract_documentation(doc: lsp_types::Documentation) -> String {
1806    match doc {
1807        lsp_types::Documentation::String(s) => s,
1808        lsp_types::Documentation::MarkupContent(m) => m.value,
1809    }
1810}
1811
1812/// Normalize a `GotoDefinitionResponse` into a flat list of MCP `Location` values.
1813fn goto_response_to_locations(
1814    response: Option<lsp_types::GotoDefinitionResponse>,
1815) -> Vec<Location> {
1816    let lsp_locs: Vec<lsp_types::Location> = match response {
1817        Some(lsp_types::GotoDefinitionResponse::Scalar(loc)) => vec![loc],
1818        Some(lsp_types::GotoDefinitionResponse::Array(locs)) => locs,
1819        Some(lsp_types::GotoDefinitionResponse::Link(links)) => links
1820            .into_iter()
1821            .map(|link| lsp_types::Location {
1822                uri: link.target_uri,
1823                range: link.target_selection_range,
1824            })
1825            .collect(),
1826        None => vec![],
1827    };
1828
1829    lsp_locs
1830        .into_iter()
1831        .map(|loc| Location {
1832            uri: loc.uri.to_string(),
1833            range: normalize_range(loc.range),
1834        })
1835        .collect()
1836}
1837
1838fn extract_hover_contents(contents: HoverContents) -> String {
1839    match contents {
1840        HoverContents::Scalar(marked_string) => marked_string_to_string(marked_string),
1841        HoverContents::Array(marked_strings) => marked_strings
1842            .into_iter()
1843            .map(marked_string_to_string)
1844            .collect::<Vec<_>>()
1845            .join("\n\n"),
1846        HoverContents::Markup(markup) => markup.value,
1847    }
1848}
1849
1850/// Convert a marked string to a plain string.
1851fn marked_string_to_string(marked: MarkedString) -> String {
1852    match marked {
1853        MarkedString::String(s) => s,
1854        MarkedString::LanguageString(ls) => format!("```{}\n{}\n```", ls.language, ls.value),
1855    }
1856}
1857
1858/// Convert LSP range to MCP range (0-based to 1-based).
1859/// Validate parameters for `handle_code_actions`.
1860fn validate_code_action_params(
1861    start_line: u32,
1862    start_character: u32,
1863    end_line: u32,
1864    end_character: u32,
1865    kind_filter: Option<&str>,
1866) -> Result<()> {
1867    const VALID_ACTION_KINDS: &[&str] = &[
1868        "quickfix",
1869        "refactor",
1870        "refactor.extract",
1871        "refactor.inline",
1872        "refactor.rewrite",
1873        "source",
1874        "source.organizeImports",
1875    ];
1876
1877    if let Some(kind) = kind_filter
1878        && !VALID_ACTION_KINDS
1879            .iter()
1880            .any(|k| k.eq_ignore_ascii_case(kind))
1881    {
1882        return Err(Error::InvalidToolParams(format!(
1883            "Invalid kind_filter: '{kind}'. Valid values: {VALID_ACTION_KINDS:?}"
1884        )));
1885    }
1886
1887    if start_line < 1 || start_character < 1 || end_line < 1 || end_character < 1 {
1888        return Err(Error::InvalidToolParams(
1889            "Line and character positions must be >= 1".to_string(),
1890        ));
1891    }
1892
1893    if start_line > MAX_POSITION_VALUE
1894        || start_character > MAX_POSITION_VALUE
1895        || end_line > MAX_POSITION_VALUE
1896        || end_character > MAX_POSITION_VALUE
1897    {
1898        return Err(Error::InvalidToolParams(format!(
1899            "Position values must be <= {MAX_POSITION_VALUE}"
1900        )));
1901    }
1902
1903    if end_line.saturating_sub(start_line) > MAX_RANGE_LINES {
1904        return Err(Error::InvalidToolParams(format!(
1905            "Range size must be <= {MAX_RANGE_LINES} lines"
1906        )));
1907    }
1908
1909    if start_line > end_line || (start_line == end_line && start_character > end_character) {
1910        return Err(Error::InvalidToolParams(
1911            "Start position must be before or equal to end position".to_string(),
1912        ));
1913    }
1914
1915    Ok(())
1916}
1917
1918/// Convert a `CallHierarchyItemResult` JSON (1-based MCP coordinates) into
1919/// a `lsp_types::CallHierarchyItem` (0-based LSP coordinates).
1920///
1921/// MCP clients receive `CallHierarchyItemResult` from `prepare_call_hierarchy`
1922/// and pass it back opaquely to `get_incoming_calls` / `get_outgoing_calls`.
1923/// The bridge serialises ranges as 1-based; this function inverts that mapping
1924/// before forwarding the item to the LSP server.
1925fn mcp_item_to_lsp(item: serde_json::Value) -> Result<CallHierarchyItem> {
1926    let mcp: CallHierarchyItemResult = serde_json::from_value(item)
1927        .map_err(|e| Error::InvalidToolParams(format!("Invalid call hierarchy item: {e}")))?;
1928
1929    let uri = mcp.uri.parse::<lsp_types::Uri>().map_err(|e| {
1930        Error::InvalidToolParams(format!("Invalid URI in call hierarchy item: {e}"))
1931    })?;
1932
1933    let detail = mcp.detail;
1934    let data = mcp.data;
1935
1936    // Round-trip via serde: `convert_call_hierarchy_item` stored the kind as a u32
1937    // by serialising `SymbolKind`; we reverse this to reconstruct the same value.
1938    let kind: lsp_types::SymbolKind = serde_json::from_value(serde_json::json!(mcp.kind))
1939        .unwrap_or(lsp_types::SymbolKind::FUNCTION);
1940
1941    Ok(CallHierarchyItem {
1942        name: mcp.name,
1943        kind,
1944        tags: None,
1945        detail,
1946        uri,
1947        range: denormalize_range(&mcp.range),
1948        selection_range: denormalize_range(&mcp.selection_range),
1949        data,
1950    })
1951}
1952
1953/// Convert a 1-based MCP range back to a 0-based LSP range.
1954///
1955/// Used when MCP clients pass back a `CallHierarchyItemResult` that was
1956/// previously returned by `prepare_call_hierarchy` (which stores 1-based coords).
1957const fn denormalize_range(range: &Range) -> lsp_types::Range {
1958    lsp_types::Range {
1959        start: lsp_types::Position {
1960            line: range.start.line.saturating_sub(1),
1961            character: range.start.character.saturating_sub(1),
1962        },
1963        end: lsp_types::Position {
1964            line: range.end.line.saturating_sub(1),
1965            character: range.end.character.saturating_sub(1),
1966        },
1967    }
1968}
1969
1970const fn normalize_range(range: lsp_types::Range) -> Range {
1971    Range {
1972        start: Position2D {
1973            line: range.start.line + 1,
1974            character: range.start.character + 1,
1975        },
1976        end: Position2D {
1977            line: range.end.line + 1,
1978            character: range.end.character + 1,
1979        },
1980    }
1981}
1982
1983/// Convert LSP document symbol to MCP symbol.
1984fn convert_document_symbol(symbol: DocumentSymbol) -> Symbol {
1985    Symbol {
1986        name: symbol.name,
1987        kind: format!("{:?}", symbol.kind),
1988        range: normalize_range(symbol.range),
1989        selection_range: normalize_range(symbol.selection_range),
1990        children: symbol
1991            .children
1992            .map(|children| children.into_iter().map(convert_document_symbol).collect()),
1993    }
1994}
1995
1996/// Convert LSP call hierarchy item to MCP call hierarchy item.
1997fn convert_call_hierarchy_item(item: CallHierarchyItem) -> CallHierarchyItemResult {
1998    CallHierarchyItemResult {
1999        name: item.name,
2000        kind: serde_json::to_value(item.kind)
2001            .ok()
2002            .and_then(|v| v.as_u64())
2003            .and_then(|n| u32::try_from(n).ok())
2004            .unwrap_or(0),
2005        detail: item.detail,
2006        uri: item.uri.to_string(),
2007        range: normalize_range(item.range),
2008        selection_range: normalize_range(item.selection_range),
2009        data: item.data,
2010    }
2011}
2012
2013/// Convert LSP code action to MCP code action.
2014fn convert_code_action(action: lsp_types::CodeAction) -> CodeAction {
2015    let diagnostics = action.diagnostics.map_or_else(Vec::new, |diags| {
2016        let mut result = Vec::with_capacity(diags.len());
2017        for d in diags {
2018            result.push(Diagnostic {
2019                range: normalize_range(d.range),
2020                severity: match d.severity {
2021                    Some(lsp_types::DiagnosticSeverity::ERROR) => DiagnosticSeverity::Error,
2022                    Some(lsp_types::DiagnosticSeverity::WARNING) => DiagnosticSeverity::Warning,
2023                    Some(lsp_types::DiagnosticSeverity::INFORMATION) => {
2024                        DiagnosticSeverity::Information
2025                    }
2026                    Some(lsp_types::DiagnosticSeverity::HINT) => DiagnosticSeverity::Hint,
2027                    _ => DiagnosticSeverity::Information,
2028                },
2029                message: d.message,
2030                code: d.code.map(|c| match c {
2031                    lsp_types::NumberOrString::Number(n) => n.to_string(),
2032                    lsp_types::NumberOrString::String(s) => s,
2033                }),
2034            });
2035        }
2036        result
2037    });
2038
2039    let edit = action.edit.map(|edit| {
2040        let changes = edit.changes.map_or_else(Vec::new, |changes_map| {
2041            let mut result = Vec::with_capacity(changes_map.len());
2042            for (uri, edits) in changes_map {
2043                let mut text_edits = Vec::with_capacity(edits.len());
2044                for e in edits {
2045                    text_edits.push(TextEdit {
2046                        range: normalize_range(e.range),
2047                        new_text: e.new_text,
2048                    });
2049                }
2050                result.push(DocumentChanges {
2051                    uri: uri.to_string(),
2052                    edits: text_edits,
2053                });
2054            }
2055            result
2056        });
2057        WorkspaceEditDescription { changes }
2058    });
2059
2060    let command = action.command.map(|cmd| {
2061        let arguments = cmd.arguments.unwrap_or_else(Vec::new);
2062        CommandDescription {
2063            title: cmd.title,
2064            command: cmd.command,
2065            arguments,
2066        }
2067    });
2068
2069    CodeAction {
2070        title: action.title,
2071        kind: action.kind.map(|k| k.as_str().to_string()),
2072        diagnostics,
2073        edit,
2074        command,
2075        is_preferred: action.is_preferred.unwrap_or(false),
2076    }
2077}
2078
2079#[cfg(test)]
2080#[allow(clippy::unwrap_used)]
2081mod tests {
2082    use std::fs;
2083
2084    use tempfile::TempDir;
2085    use url::Url;
2086
2087    use super::*;
2088
2089    #[test]
2090    fn test_translator_new() {
2091        let translator = Translator::new();
2092        assert_eq!(translator.workspace_roots.len(), 0);
2093        assert_eq!(translator.lsp_clients.len(), 0);
2094        assert_eq!(translator.lsp_servers.len(), 0);
2095    }
2096
2097    #[test]
2098    fn test_set_workspace_roots() {
2099        let mut translator = Translator::new();
2100        let roots = vec![PathBuf::from("/test/root1"), PathBuf::from("/test/root2")];
2101        translator.set_workspace_roots(roots.clone());
2102        assert_eq!(translator.workspace_roots, roots);
2103    }
2104
2105    #[test]
2106    fn test_register_server() {
2107        let translator = Translator::new();
2108
2109        // Initial state: no servers registered
2110        assert_eq!(translator.lsp_servers.len(), 0);
2111
2112        // The register_server method exists and is callable
2113        // Full integration testing with real LspServer is done in integration tests
2114        // This unit test verifies the method signature and basic functionality
2115
2116        // Note: We can't easily construct an LspServer in a unit test without async
2117        // and a real LSP server process. The actual registration functionality is
2118        // tested in integration tests (see rust_analyzer_tests.rs).
2119        // This test verifies the data structure is properly initialized.
2120    }
2121
2122    #[test]
2123    fn test_get_client_for_file_server_initializing_when_expected() {
2124        // A configured/applicable language whose LSP client has not registered
2125        // yet (large solution still loading via OmniSharp) must surface
2126        // ServerInitializing — "wait and retry" — not NoServerForLanguage.
2127        let mut translator = Translator::new();
2128        let path = PathBuf::from("/ws/Assets/Scripts/Player.cs");
2129        let lang = detect_language(&path, &translator.extension_map);
2130
2131        let mut expected = HashSet::new();
2132        expected.insert(lang.clone());
2133        translator.set_expected_languages(expected);
2134
2135        let err = translator.get_client_for_file(&path).unwrap_err();
2136        assert!(matches!(err, Error::ServerInitializing(ref l) if *l == lang));
2137    }
2138
2139    #[test]
2140    fn test_get_client_for_file_no_server_when_not_expected() {
2141        // When the language is not in the expected set (no server configured for
2142        // it at all), the error stays NoServerForLanguage.
2143        let translator = Translator::new();
2144        let path = PathBuf::from("/ws/Assets/Scripts/Player.cs");
2145        let lang = detect_language(&path, &translator.extension_map);
2146
2147        let err = translator.get_client_for_file(&path).unwrap_err();
2148        assert!(matches!(err, Error::NoServerForLanguage(ref l) if *l == lang));
2149    }
2150
2151    #[test]
2152    fn test_clear_expected_languages_reverts_to_no_server() {
2153        // After initialization fails the expected set is cleared; subsequent
2154        // lookups must fall back to NoServerForLanguage rather than keep
2155        // implying the server is still on its way.
2156        let mut translator = Translator::new();
2157        let path = PathBuf::from("/ws/Assets/Scripts/Player.cs");
2158        let lang = detect_language(&path, &translator.extension_map);
2159
2160        let mut expected = HashSet::new();
2161        expected.insert(lang);
2162        translator.set_expected_languages(expected);
2163        translator.clear_expected_languages();
2164
2165        let err = translator.get_client_for_file(&path).unwrap_err();
2166        assert!(matches!(err, Error::NoServerForLanguage(_)));
2167    }
2168
2169    #[test]
2170    fn test_diagnostic_request_params_omit_optional_null_fields() {
2171        let uri = "file:///test.ts".parse().unwrap();
2172        let params = diagnostic_request_params(TextDocumentIdentifier { uri });
2173        let value = serde_json::to_value(params).unwrap();
2174
2175        assert_eq!(value["textDocument"]["uri"], "file:///test.ts");
2176        assert!(value.get("identifier").is_none());
2177        assert!(value.get("previousResultId").is_none());
2178    }
2179
2180    #[test]
2181    fn test_validate_path_no_workspace_roots() {
2182        let translator = Translator::new();
2183        let temp_dir = TempDir::new().unwrap();
2184        let test_file = temp_dir.path().join("test.rs");
2185        fs::write(&test_file, "fn main() {}").unwrap();
2186
2187        // With no workspace roots, any valid path should be accepted
2188        let result = translator.validate_path(&test_file);
2189        assert!(result.is_ok());
2190    }
2191
2192    #[test]
2193    fn test_validate_path_within_workspace() {
2194        let mut translator = Translator::new();
2195        let temp_dir = TempDir::new().unwrap();
2196        let workspace_root = temp_dir.path().to_path_buf();
2197        translator.set_workspace_roots(vec![workspace_root]);
2198
2199        let test_file = temp_dir.path().join("test.rs");
2200        fs::write(&test_file, "fn main() {}").unwrap();
2201
2202        let result = translator.validate_path(&test_file);
2203        assert!(result.is_ok());
2204    }
2205
2206    #[test]
2207    fn test_validate_path_outside_workspace() {
2208        let mut translator = Translator::new();
2209        let temp_dir1 = TempDir::new().unwrap();
2210        let temp_dir2 = TempDir::new().unwrap();
2211
2212        // Set workspace root to temp_dir1
2213        translator.set_workspace_roots(vec![temp_dir1.path().to_path_buf()]);
2214
2215        // Create file in temp_dir2 (outside workspace)
2216        let test_file = temp_dir2.path().join("test.rs");
2217        fs::write(&test_file, "fn main() {}").unwrap();
2218
2219        let result = translator.validate_path(&test_file);
2220        assert!(matches!(result, Err(Error::PathOutsideWorkspace(_))));
2221    }
2222
2223    #[test]
2224    fn test_normalize_range() {
2225        let lsp_range = lsp_types::Range {
2226            start: lsp_types::Position {
2227                line: 0,
2228                character: 0,
2229            },
2230            end: lsp_types::Position {
2231                line: 2,
2232                character: 5,
2233            },
2234        };
2235
2236        let mcp_range = normalize_range(lsp_range);
2237        assert_eq!(mcp_range.start.line, 1);
2238        assert_eq!(mcp_range.start.character, 1);
2239        assert_eq!(mcp_range.end.line, 3);
2240        assert_eq!(mcp_range.end.character, 6);
2241    }
2242
2243    #[test]
2244    fn test_extract_hover_contents_string() {
2245        let marked_string = lsp_types::MarkedString::String("Test hover".to_string());
2246        let contents = lsp_types::HoverContents::Scalar(marked_string);
2247        let result = extract_hover_contents(contents);
2248        assert_eq!(result, "Test hover");
2249    }
2250
2251    #[test]
2252    fn test_extract_hover_contents_language_string() {
2253        let marked_string = lsp_types::MarkedString::LanguageString(lsp_types::LanguageString {
2254            language: "rust".to_string(),
2255            value: "fn main() {}".to_string(),
2256        });
2257        let contents = lsp_types::HoverContents::Scalar(marked_string);
2258        let result = extract_hover_contents(contents);
2259        assert_eq!(result, "```rust\nfn main() {}\n```");
2260    }
2261
2262    #[test]
2263    fn test_extract_hover_contents_markup() {
2264        let markup = lsp_types::MarkupContent {
2265            kind: lsp_types::MarkupKind::Markdown,
2266            value: "# Documentation".to_string(),
2267        };
2268        let contents = lsp_types::HoverContents::Markup(markup);
2269        let result = extract_hover_contents(contents);
2270        assert_eq!(result, "# Documentation");
2271    }
2272
2273    #[tokio::test]
2274    async fn test_handle_workspace_symbol_no_server() {
2275        let mut translator = Translator::new();
2276        let result = translator
2277            .handle_workspace_symbol("test".to_string(), None, 100)
2278            .await;
2279        assert!(matches!(result, Err(Error::NoServerConfigured)));
2280    }
2281
2282    #[tokio::test]
2283    async fn test_handle_code_actions_invalid_kind() {
2284        let mut translator = Translator::new();
2285        let result = translator
2286            .handle_code_actions(
2287                "/tmp/test.rs".to_string(),
2288                1,
2289                1,
2290                1,
2291                10,
2292                Some("invalid_kind".to_string()),
2293            )
2294            .await;
2295        assert!(matches!(result, Err(Error::InvalidToolParams(_))));
2296    }
2297
2298    #[tokio::test]
2299    async fn test_handle_code_actions_valid_kind_quickfix() {
2300        use tempfile::TempDir;
2301
2302        let mut translator = Translator::new();
2303        let temp_dir = TempDir::new().unwrap();
2304        let test_file = temp_dir.path().join("test.rs");
2305        fs::write(&test_file, "fn main() {}").unwrap();
2306
2307        let result = translator
2308            .handle_code_actions(
2309                test_file.to_str().unwrap().to_string(),
2310                1,
2311                1,
2312                1,
2313                10,
2314                Some("quickfix".to_string()),
2315            )
2316            .await;
2317        // Will fail due to no LSP server, but validates kind is accepted
2318        assert!(result.is_err());
2319        assert!(!matches!(result, Err(Error::InvalidToolParams(_))));
2320    }
2321
2322    #[tokio::test]
2323    async fn test_handle_code_actions_valid_kind_refactor() {
2324        use tempfile::TempDir;
2325
2326        let mut translator = Translator::new();
2327        let temp_dir = TempDir::new().unwrap();
2328        let test_file = temp_dir.path().join("test.rs");
2329        fs::write(&test_file, "fn main() {}").unwrap();
2330
2331        let result = translator
2332            .handle_code_actions(
2333                test_file.to_str().unwrap().to_string(),
2334                1,
2335                1,
2336                1,
2337                10,
2338                Some("refactor".to_string()),
2339            )
2340            .await;
2341        assert!(result.is_err());
2342        assert!(!matches!(result, Err(Error::InvalidToolParams(_))));
2343    }
2344
2345    #[tokio::test]
2346    async fn test_handle_code_actions_valid_kind_refactor_extract() {
2347        use tempfile::TempDir;
2348
2349        let mut translator = Translator::new();
2350        let temp_dir = TempDir::new().unwrap();
2351        let test_file = temp_dir.path().join("test.rs");
2352        fs::write(&test_file, "fn main() {}").unwrap();
2353
2354        let result = translator
2355            .handle_code_actions(
2356                test_file.to_str().unwrap().to_string(),
2357                1,
2358                1,
2359                1,
2360                10,
2361                Some("refactor.extract".to_string()),
2362            )
2363            .await;
2364        assert!(result.is_err());
2365        assert!(!matches!(result, Err(Error::InvalidToolParams(_))));
2366    }
2367
2368    #[tokio::test]
2369    async fn test_handle_code_actions_valid_kind_source() {
2370        use tempfile::TempDir;
2371
2372        let mut translator = Translator::new();
2373        let temp_dir = TempDir::new().unwrap();
2374        let test_file = temp_dir.path().join("test.rs");
2375        fs::write(&test_file, "fn main() {}").unwrap();
2376
2377        let result = translator
2378            .handle_code_actions(
2379                test_file.to_str().unwrap().to_string(),
2380                1,
2381                1,
2382                1,
2383                10,
2384                Some("source.organizeImports".to_string()),
2385            )
2386            .await;
2387        assert!(result.is_err());
2388        assert!(!matches!(result, Err(Error::InvalidToolParams(_))));
2389    }
2390
2391    #[tokio::test]
2392    async fn test_handle_code_actions_invalid_range_zero() {
2393        let mut translator = Translator::new();
2394        let result = translator
2395            .handle_code_actions("/tmp/test.rs".to_string(), 0, 1, 1, 10, None)
2396            .await;
2397        assert!(matches!(result, Err(Error::InvalidToolParams(_))));
2398    }
2399
2400    #[tokio::test]
2401    async fn test_handle_code_actions_invalid_range_order() {
2402        let mut translator = Translator::new();
2403        let result = translator
2404            .handle_code_actions("/tmp/test.rs".to_string(), 10, 5, 5, 1, None)
2405            .await;
2406        assert!(matches!(result, Err(Error::InvalidToolParams(_))));
2407    }
2408
2409    #[tokio::test]
2410    async fn test_handle_code_actions_empty_range() {
2411        use tempfile::TempDir;
2412
2413        let mut translator = Translator::new();
2414        let temp_dir = TempDir::new().unwrap();
2415        let test_file = temp_dir.path().join("test.rs");
2416        fs::write(&test_file, "fn main() {}").unwrap();
2417
2418        // Empty range (same position) should be valid
2419        let result = translator
2420            .handle_code_actions(test_file.to_str().unwrap().to_string(), 1, 5, 1, 5, None)
2421            .await;
2422        // Will fail due to no LSP server, but validates range is accepted
2423        assert!(result.is_err());
2424        assert!(!matches!(result, Err(Error::InvalidToolParams(_))));
2425    }
2426
2427    #[test]
2428    fn test_convert_code_action_minimal() {
2429        let lsp_action = lsp_types::CodeAction {
2430            title: "Fix issue".to_string(),
2431            kind: None,
2432            diagnostics: None,
2433            edit: None,
2434            command: None,
2435            is_preferred: None,
2436            disabled: None,
2437            data: None,
2438        };
2439
2440        let result = convert_code_action(lsp_action);
2441        assert_eq!(result.title, "Fix issue");
2442        assert!(result.kind.is_none());
2443        assert!(result.diagnostics.is_empty());
2444        assert!(result.edit.is_none());
2445        assert!(result.command.is_none());
2446        assert!(!result.is_preferred);
2447    }
2448
2449    #[test]
2450    #[allow(clippy::too_many_lines)]
2451    fn test_convert_code_action_with_diagnostics_all_severities() {
2452        let lsp_diagnostics = vec![
2453            lsp_types::Diagnostic {
2454                range: lsp_types::Range {
2455                    start: lsp_types::Position {
2456                        line: 0,
2457                        character: 0,
2458                    },
2459                    end: lsp_types::Position {
2460                        line: 0,
2461                        character: 5,
2462                    },
2463                },
2464                severity: Some(lsp_types::DiagnosticSeverity::ERROR),
2465                message: "Error message".to_string(),
2466                code: Some(lsp_types::NumberOrString::Number(1)),
2467                source: None,
2468                code_description: None,
2469                related_information: None,
2470                tags: None,
2471                data: None,
2472            },
2473            lsp_types::Diagnostic {
2474                range: lsp_types::Range {
2475                    start: lsp_types::Position {
2476                        line: 1,
2477                        character: 0,
2478                    },
2479                    end: lsp_types::Position {
2480                        line: 1,
2481                        character: 5,
2482                    },
2483                },
2484                severity: Some(lsp_types::DiagnosticSeverity::WARNING),
2485                message: "Warning message".to_string(),
2486                code: Some(lsp_types::NumberOrString::String("W001".to_string())),
2487                source: None,
2488                code_description: None,
2489                related_information: None,
2490                tags: None,
2491                data: None,
2492            },
2493            lsp_types::Diagnostic {
2494                range: lsp_types::Range {
2495                    start: lsp_types::Position {
2496                        line: 2,
2497                        character: 0,
2498                    },
2499                    end: lsp_types::Position {
2500                        line: 2,
2501                        character: 5,
2502                    },
2503                },
2504                severity: Some(lsp_types::DiagnosticSeverity::INFORMATION),
2505                message: "Info message".to_string(),
2506                code: None,
2507                source: None,
2508                code_description: None,
2509                related_information: None,
2510                tags: None,
2511                data: None,
2512            },
2513            lsp_types::Diagnostic {
2514                range: lsp_types::Range {
2515                    start: lsp_types::Position {
2516                        line: 3,
2517                        character: 0,
2518                    },
2519                    end: lsp_types::Position {
2520                        line: 3,
2521                        character: 5,
2522                    },
2523                },
2524                severity: Some(lsp_types::DiagnosticSeverity::HINT),
2525                message: "Hint message".to_string(),
2526                code: None,
2527                source: None,
2528                code_description: None,
2529                related_information: None,
2530                tags: None,
2531                data: None,
2532            },
2533        ];
2534
2535        let lsp_action = lsp_types::CodeAction {
2536            title: "Fix all issues".to_string(),
2537            kind: Some(lsp_types::CodeActionKind::QUICKFIX),
2538            diagnostics: Some(lsp_diagnostics),
2539            edit: None,
2540            command: None,
2541            is_preferred: None,
2542            disabled: None,
2543            data: None,
2544        };
2545
2546        let result = convert_code_action(lsp_action);
2547        assert_eq!(result.diagnostics.len(), 4);
2548        assert!(matches!(
2549            result.diagnostics[0].severity,
2550            DiagnosticSeverity::Error
2551        ));
2552        assert!(matches!(
2553            result.diagnostics[1].severity,
2554            DiagnosticSeverity::Warning
2555        ));
2556        assert!(matches!(
2557            result.diagnostics[2].severity,
2558            DiagnosticSeverity::Information
2559        ));
2560        assert!(matches!(
2561            result.diagnostics[3].severity,
2562            DiagnosticSeverity::Hint
2563        ));
2564        assert_eq!(result.diagnostics[0].code, Some("1".to_string()));
2565        assert_eq!(result.diagnostics[1].code, Some("W001".to_string()));
2566    }
2567
2568    #[test]
2569    #[allow(clippy::mutable_key_type)]
2570    fn test_convert_code_action_with_workspace_edit() {
2571        use std::collections::HashMap;
2572        use std::str::FromStr;
2573
2574        let uri = lsp_types::Uri::from_str("file:///test.rs").unwrap();
2575        let mut changes_map = HashMap::new();
2576        changes_map.insert(
2577            uri,
2578            vec![lsp_types::TextEdit {
2579                range: lsp_types::Range {
2580                    start: lsp_types::Position {
2581                        line: 0,
2582                        character: 0,
2583                    },
2584                    end: lsp_types::Position {
2585                        line: 0,
2586                        character: 5,
2587                    },
2588                },
2589                new_text: "fixed".to_string(),
2590            }],
2591        );
2592
2593        let lsp_action = lsp_types::CodeAction {
2594            title: "Apply fix".to_string(),
2595            kind: Some(lsp_types::CodeActionKind::QUICKFIX),
2596            diagnostics: None,
2597            edit: Some(lsp_types::WorkspaceEdit {
2598                changes: Some(changes_map),
2599                document_changes: None,
2600                change_annotations: None,
2601            }),
2602            command: None,
2603            is_preferred: Some(true),
2604            disabled: None,
2605            data: None,
2606        };
2607
2608        let result = convert_code_action(lsp_action);
2609        assert!(result.edit.is_some());
2610        let edit = result.edit.unwrap();
2611        assert_eq!(edit.changes.len(), 1);
2612        assert_eq!(edit.changes[0].uri, "file:///test.rs");
2613        assert_eq!(edit.changes[0].edits.len(), 1);
2614        assert_eq!(edit.changes[0].edits[0].new_text, "fixed");
2615        assert!(result.is_preferred);
2616    }
2617
2618    #[test]
2619    fn test_convert_code_action_with_command() {
2620        let lsp_action = lsp_types::CodeAction {
2621            title: "Run command".to_string(),
2622            kind: Some(lsp_types::CodeActionKind::REFACTOR),
2623            diagnostics: None,
2624            edit: None,
2625            command: Some(lsp_types::Command {
2626                title: "Execute refactor".to_string(),
2627                command: "refactor.extract".to_string(),
2628                arguments: Some(vec![serde_json::json!("arg1"), serde_json::json!(42)]),
2629            }),
2630            is_preferred: None,
2631            disabled: None,
2632            data: None,
2633        };
2634
2635        let result = convert_code_action(lsp_action);
2636        assert!(result.command.is_some());
2637        let cmd = result.command.unwrap();
2638        assert_eq!(cmd.title, "Execute refactor");
2639        assert_eq!(cmd.command, "refactor.extract");
2640        assert_eq!(cmd.arguments.len(), 2);
2641    }
2642
2643    #[tokio::test]
2644    async fn test_handle_call_hierarchy_prepare_invalid_position_zero() {
2645        let mut translator = Translator::new();
2646        let result = translator
2647            .handle_call_hierarchy_prepare("/tmp/test.rs".to_string(), 0, 1)
2648            .await;
2649        assert!(matches!(result, Err(Error::InvalidToolParams(_))));
2650
2651        let result = translator
2652            .handle_call_hierarchy_prepare("/tmp/test.rs".to_string(), 1, 0)
2653            .await;
2654        assert!(matches!(result, Err(Error::InvalidToolParams(_))));
2655    }
2656
2657    #[tokio::test]
2658    async fn test_handle_call_hierarchy_prepare_invalid_position_too_large() {
2659        let mut translator = Translator::new();
2660        let result = translator
2661            .handle_call_hierarchy_prepare("/tmp/test.rs".to_string(), 1_000_001, 1)
2662            .await;
2663        assert!(matches!(result, Err(Error::InvalidToolParams(_))));
2664
2665        let result = translator
2666            .handle_call_hierarchy_prepare("/tmp/test.rs".to_string(), 1, 1_000_001)
2667            .await;
2668        assert!(matches!(result, Err(Error::InvalidToolParams(_))));
2669    }
2670
2671    #[tokio::test]
2672    async fn test_handle_incoming_calls_invalid_json() {
2673        let mut translator = Translator::new();
2674        let invalid_item = serde_json::json!({"invalid": "structure"});
2675        let result = translator.handle_incoming_calls(invalid_item).await;
2676        assert!(matches!(result, Err(Error::InvalidToolParams(_))));
2677    }
2678
2679    #[tokio::test]
2680    async fn test_handle_outgoing_calls_invalid_json() {
2681        let mut translator = Translator::new();
2682        let invalid_item = serde_json::json!({"invalid": "structure"});
2683        let result = translator.handle_outgoing_calls(invalid_item).await;
2684        assert!(matches!(result, Err(Error::InvalidToolParams(_))));
2685    }
2686
2687    #[tokio::test]
2688    async fn test_parse_file_uri_invalid_scheme() {
2689        let translator = Translator::new();
2690        let uri: lsp_types::Uri = "http://example.com/file.rs".parse().unwrap();
2691        let result = translator.parse_file_uri(&uri);
2692        assert!(matches!(result, Err(Error::InvalidToolParams(_))));
2693    }
2694
2695    #[tokio::test]
2696    async fn test_parse_file_uri_valid_scheme() {
2697        let translator = Translator::new();
2698        let temp_dir = TempDir::new().unwrap();
2699        let test_file = temp_dir.path().join("test.rs");
2700        fs::write(&test_file, "fn main() {}").unwrap();
2701
2702        // Use url crate for cross-platform file URI creation
2703        let file_url = Url::from_file_path(&test_file).unwrap();
2704        let uri: lsp_types::Uri = file_url.as_str().parse().unwrap();
2705        let result = translator.parse_file_uri(&uri);
2706        assert!(result.is_ok());
2707    }
2708
2709    #[test]
2710    fn test_handle_cached_diagnostics_empty() {
2711        let mut translator = Translator::new();
2712        let temp_dir = TempDir::new().unwrap();
2713        let test_file = temp_dir.path().join("test.rs");
2714        fs::write(&test_file, "fn main() {}").unwrap();
2715
2716        let result = translator.handle_cached_diagnostics(test_file.to_str().unwrap());
2717        assert!(result.is_ok());
2718        let diags = result.unwrap();
2719        assert_eq!(diags.diagnostics.len(), 0);
2720    }
2721
2722    #[test]
2723    fn test_handle_server_logs_with_filter() {
2724        use crate::bridge::notifications::LogLevel;
2725
2726        let mut translator = Translator::new();
2727
2728        // Add some logs
2729        translator
2730            .notification_cache_mut()
2731            .store_log(LogLevel::Error, "error msg".to_string());
2732        translator
2733            .notification_cache_mut()
2734            .store_log(LogLevel::Warning, "warning msg".to_string());
2735        translator
2736            .notification_cache_mut()
2737            .store_log(LogLevel::Info, "info msg".to_string());
2738        translator
2739            .notification_cache_mut()
2740            .store_log(LogLevel::Debug, "debug msg".to_string());
2741
2742        // Test with error filter
2743        let result = translator.handle_server_logs(10, Some("error".to_string()));
2744        assert!(result.is_ok());
2745        let logs = result.unwrap();
2746        assert_eq!(logs.logs.len(), 1);
2747        assert_eq!(logs.logs[0].message, "error msg");
2748
2749        // Test with warning filter (includes error and warning)
2750        let result = translator.handle_server_logs(10, Some("warning".to_string()));
2751        assert!(result.is_ok());
2752        let logs = result.unwrap();
2753        assert_eq!(logs.logs.len(), 2);
2754
2755        // Test with info filter (excludes debug)
2756        let result = translator.handle_server_logs(10, Some("info".to_string()));
2757        assert!(result.is_ok());
2758        let logs = result.unwrap();
2759        assert_eq!(logs.logs.len(), 3);
2760
2761        // Test with debug filter (includes all)
2762        let result = translator.handle_server_logs(10, Some("debug".to_string()));
2763        assert!(result.is_ok());
2764        let logs = result.unwrap();
2765        assert_eq!(logs.logs.len(), 4);
2766
2767        // Test with invalid filter
2768        let result = translator.handle_server_logs(10, Some("invalid".to_string()));
2769        assert!(matches!(result, Err(Error::InvalidToolParams(_))));
2770    }
2771
2772    #[test]
2773    fn test_handle_server_messages_limit() {
2774        use crate::bridge::notifications::MessageType;
2775
2776        let mut translator = Translator::new();
2777
2778        // Add some messages
2779        for i in 0..10 {
2780            translator
2781                .notification_cache_mut()
2782                .store_message(MessageType::Info, format!("message {i}"));
2783        }
2784
2785        // Test limit
2786        let result = translator.handle_server_messages(5);
2787        assert!(result.is_ok());
2788        let messages = result.unwrap();
2789        assert_eq!(messages.messages.len(), 5);
2790        assert_eq!(messages.messages[0].message, "message 0");
2791        assert_eq!(messages.messages[4].message, "message 4");
2792
2793        // Test limit larger than available
2794        let result = translator.handle_server_messages(100);
2795        assert!(result.is_ok());
2796        let messages = result.unwrap();
2797        assert_eq!(messages.messages.len(), 10);
2798    }
2799
2800    #[test]
2801    fn test_handle_cached_diagnostics_with_data() {
2802        let mut translator = Translator::new();
2803        let temp_dir = TempDir::new().unwrap();
2804        let test_file = temp_dir.path().join("test.rs");
2805        fs::write(&test_file, "fn main() {}").unwrap();
2806
2807        let canonical_path = test_file.canonicalize().unwrap();
2808        let uri: lsp_types::Uri = Url::from_file_path(&canonical_path)
2809            .unwrap()
2810            .as_str()
2811            .parse()
2812            .unwrap();
2813        let diagnostic = lsp_types::Diagnostic {
2814            range: lsp_types::Range {
2815                start: lsp_types::Position {
2816                    line: 0,
2817                    character: 0,
2818                },
2819                end: lsp_types::Position {
2820                    line: 0,
2821                    character: 5,
2822                },
2823            },
2824            severity: Some(lsp_types::DiagnosticSeverity::ERROR),
2825            message: "test error".to_string(),
2826            code: Some(lsp_types::NumberOrString::String("E001".to_string())),
2827            source: None,
2828            code_description: None,
2829            related_information: None,
2830            tags: None,
2831            data: None,
2832        };
2833
2834        translator
2835            .notification_cache_mut()
2836            .store_diagnostics(&uri, Some(1), vec![diagnostic]);
2837
2838        let result = translator.handle_cached_diagnostics(test_file.to_str().unwrap());
2839        assert!(result.is_ok());
2840        let diags = result.unwrap();
2841        assert_eq!(diags.diagnostics.len(), 1);
2842        assert_eq!(diags.diagnostics[0].message, "test error");
2843        assert_eq!(diags.diagnostics[0].code, Some("E001".to_string()));
2844        assert!(matches!(
2845            diags.diagnostics[0].severity,
2846            DiagnosticSeverity::Error
2847        ));
2848        assert_eq!(diags.diagnostics[0].range.start.line, 1);
2849        assert_eq!(diags.diagnostics[0].range.start.character, 1);
2850    }
2851
2852    #[test]
2853    #[allow(clippy::too_many_lines)]
2854    fn test_handle_cached_diagnostics_multiple_severities() {
2855        let mut translator = Translator::new();
2856        let temp_dir = TempDir::new().unwrap();
2857        let test_file = temp_dir.path().join("test.rs");
2858        fs::write(&test_file, "fn main() {}").unwrap();
2859
2860        let canonical_path = test_file.canonicalize().unwrap();
2861        let uri: lsp_types::Uri = Url::from_file_path(&canonical_path)
2862            .unwrap()
2863            .as_str()
2864            .parse()
2865            .unwrap();
2866        let diagnostics = vec![
2867            lsp_types::Diagnostic {
2868                range: lsp_types::Range {
2869                    start: lsp_types::Position {
2870                        line: 0,
2871                        character: 0,
2872                    },
2873                    end: lsp_types::Position {
2874                        line: 0,
2875                        character: 5,
2876                    },
2877                },
2878                severity: Some(lsp_types::DiagnosticSeverity::ERROR),
2879                message: "error".to_string(),
2880                code: None,
2881                source: None,
2882                code_description: None,
2883                related_information: None,
2884                tags: None,
2885                data: None,
2886            },
2887            lsp_types::Diagnostic {
2888                range: lsp_types::Range {
2889                    start: lsp_types::Position {
2890                        line: 1,
2891                        character: 0,
2892                    },
2893                    end: lsp_types::Position {
2894                        line: 1,
2895                        character: 5,
2896                    },
2897                },
2898                severity: Some(lsp_types::DiagnosticSeverity::WARNING),
2899                message: "warning".to_string(),
2900                code: None,
2901                source: None,
2902                code_description: None,
2903                related_information: None,
2904                tags: None,
2905                data: None,
2906            },
2907            lsp_types::Diagnostic {
2908                range: lsp_types::Range {
2909                    start: lsp_types::Position {
2910                        line: 2,
2911                        character: 0,
2912                    },
2913                    end: lsp_types::Position {
2914                        line: 2,
2915                        character: 5,
2916                    },
2917                },
2918                severity: Some(lsp_types::DiagnosticSeverity::INFORMATION),
2919                message: "info".to_string(),
2920                code: None,
2921                source: None,
2922                code_description: None,
2923                related_information: None,
2924                tags: None,
2925                data: None,
2926            },
2927            lsp_types::Diagnostic {
2928                range: lsp_types::Range {
2929                    start: lsp_types::Position {
2930                        line: 3,
2931                        character: 0,
2932                    },
2933                    end: lsp_types::Position {
2934                        line: 3,
2935                        character: 5,
2936                    },
2937                },
2938                severity: Some(lsp_types::DiagnosticSeverity::HINT),
2939                message: "hint".to_string(),
2940                code: None,
2941                source: None,
2942                code_description: None,
2943                related_information: None,
2944                tags: None,
2945                data: None,
2946            },
2947        ];
2948
2949        translator
2950            .notification_cache_mut()
2951            .store_diagnostics(&uri, Some(1), diagnostics);
2952
2953        let result = translator.handle_cached_diagnostics(test_file.to_str().unwrap());
2954        assert!(result.is_ok());
2955        let diags = result.unwrap();
2956        assert_eq!(diags.diagnostics.len(), 4);
2957        assert!(matches!(
2958            diags.diagnostics[0].severity,
2959            DiagnosticSeverity::Error
2960        ));
2961        assert!(matches!(
2962            diags.diagnostics[1].severity,
2963            DiagnosticSeverity::Warning
2964        ));
2965        assert!(matches!(
2966            diags.diagnostics[2].severity,
2967            DiagnosticSeverity::Information
2968        ));
2969        assert!(matches!(
2970            diags.diagnostics[3].severity,
2971            DiagnosticSeverity::Hint
2972        ));
2973    }
2974
2975    #[test]
2976    fn test_handle_cached_diagnostics_with_numeric_code() {
2977        let mut translator = Translator::new();
2978        let temp_dir = TempDir::new().unwrap();
2979        let test_file = temp_dir.path().join("test.rs");
2980        fs::write(&test_file, "fn main() {}").unwrap();
2981
2982        let canonical_path = test_file.canonicalize().unwrap();
2983        let uri: lsp_types::Uri = Url::from_file_path(&canonical_path)
2984            .unwrap()
2985            .as_str()
2986            .parse()
2987            .unwrap();
2988        let diagnostic = lsp_types::Diagnostic {
2989            range: lsp_types::Range {
2990                start: lsp_types::Position {
2991                    line: 0,
2992                    character: 0,
2993                },
2994                end: lsp_types::Position {
2995                    line: 0,
2996                    character: 5,
2997                },
2998            },
2999            severity: Some(lsp_types::DiagnosticSeverity::ERROR),
3000            message: "test error".to_string(),
3001            code: Some(lsp_types::NumberOrString::Number(42)),
3002            source: None,
3003            code_description: None,
3004            related_information: None,
3005            tags: None,
3006            data: None,
3007        };
3008
3009        translator
3010            .notification_cache_mut()
3011            .store_diagnostics(&uri, Some(1), vec![diagnostic]);
3012
3013        let result = translator.handle_cached_diagnostics(test_file.to_str().unwrap());
3014        assert!(result.is_ok());
3015        let diags = result.unwrap();
3016        assert_eq!(diags.diagnostics.len(), 1);
3017        assert_eq!(diags.diagnostics[0].code, Some("42".to_string()));
3018    }
3019
3020    #[test]
3021    fn test_handle_cached_diagnostics_invalid_path() {
3022        let mut translator = Translator::new();
3023        let result = translator.handle_cached_diagnostics("/nonexistent/path/file.rs");
3024        assert!(matches!(result, Err(Error::FileIo { .. })));
3025    }
3026
3027    #[test]
3028    fn test_handle_server_logs_no_filter() {
3029        use crate::bridge::notifications::LogLevel;
3030
3031        let mut translator = Translator::new();
3032
3033        translator
3034            .notification_cache_mut()
3035            .store_log(LogLevel::Error, "error msg".to_string());
3036        translator
3037            .notification_cache_mut()
3038            .store_log(LogLevel::Warning, "warning msg".to_string());
3039        translator
3040            .notification_cache_mut()
3041            .store_log(LogLevel::Info, "info msg".to_string());
3042        translator
3043            .notification_cache_mut()
3044            .store_log(LogLevel::Debug, "debug msg".to_string());
3045
3046        let result = translator.handle_server_logs(10, None);
3047        assert!(result.is_ok());
3048        let logs = result.unwrap();
3049        assert_eq!(logs.logs.len(), 4);
3050    }
3051
3052    #[test]
3053    fn test_handle_server_logs_error_filter_strict() {
3054        use crate::bridge::notifications::LogLevel;
3055
3056        let mut translator = Translator::new();
3057
3058        translator
3059            .notification_cache_mut()
3060            .store_log(LogLevel::Error, "error msg".to_string());
3061        translator
3062            .notification_cache_mut()
3063            .store_log(LogLevel::Warning, "warning msg".to_string());
3064        translator
3065            .notification_cache_mut()
3066            .store_log(LogLevel::Info, "info msg".to_string());
3067
3068        let result = translator.handle_server_logs(10, Some("error".to_string()));
3069        assert!(result.is_ok());
3070        let logs = result.unwrap();
3071        assert_eq!(logs.logs.len(), 1);
3072        assert_eq!(logs.logs[0].message, "error msg");
3073    }
3074
3075    #[test]
3076    fn test_handle_server_logs_warning_filter_includes_errors() {
3077        use crate::bridge::notifications::LogLevel;
3078
3079        let mut translator = Translator::new();
3080
3081        translator
3082            .notification_cache_mut()
3083            .store_log(LogLevel::Error, "error msg".to_string());
3084        translator
3085            .notification_cache_mut()
3086            .store_log(LogLevel::Warning, "warning msg".to_string());
3087        translator
3088            .notification_cache_mut()
3089            .store_log(LogLevel::Info, "info msg".to_string());
3090
3091        let result = translator.handle_server_logs(10, Some("warning".to_string()));
3092        assert!(result.is_ok());
3093        let logs = result.unwrap();
3094        assert_eq!(logs.logs.len(), 2);
3095    }
3096
3097    #[test]
3098    fn test_handle_server_logs_info_filter_excludes_debug() {
3099        use crate::bridge::notifications::LogLevel;
3100
3101        let mut translator = Translator::new();
3102
3103        translator
3104            .notification_cache_mut()
3105            .store_log(LogLevel::Error, "error msg".to_string());
3106        translator
3107            .notification_cache_mut()
3108            .store_log(LogLevel::Info, "info msg".to_string());
3109        translator
3110            .notification_cache_mut()
3111            .store_log(LogLevel::Debug, "debug msg".to_string());
3112
3113        let result = translator.handle_server_logs(10, Some("info".to_string()));
3114        assert!(result.is_ok());
3115        let logs = result.unwrap();
3116        assert_eq!(logs.logs.len(), 2);
3117    }
3118
3119    #[test]
3120    fn test_handle_server_logs_debug_filter_includes_all() {
3121        use crate::bridge::notifications::LogLevel;
3122
3123        let mut translator = Translator::new();
3124
3125        translator
3126            .notification_cache_mut()
3127            .store_log(LogLevel::Error, "error msg".to_string());
3128        translator
3129            .notification_cache_mut()
3130            .store_log(LogLevel::Warning, "warning msg".to_string());
3131        translator
3132            .notification_cache_mut()
3133            .store_log(LogLevel::Info, "info msg".to_string());
3134        translator
3135            .notification_cache_mut()
3136            .store_log(LogLevel::Debug, "debug msg".to_string());
3137
3138        let result = translator.handle_server_logs(10, Some("debug".to_string()));
3139        assert!(result.is_ok());
3140        let logs = result.unwrap();
3141        assert_eq!(logs.logs.len(), 4);
3142    }
3143
3144    #[test]
3145    fn test_handle_server_logs_limit_applies_after_filter() {
3146        use crate::bridge::notifications::LogLevel;
3147
3148        let mut translator = Translator::new();
3149
3150        for i in 0..10 {
3151            translator
3152                .notification_cache_mut()
3153                .store_log(LogLevel::Error, format!("error {i}"));
3154        }
3155
3156        let result = translator.handle_server_logs(5, Some("error".to_string()));
3157        assert!(result.is_ok());
3158        let logs = result.unwrap();
3159        assert_eq!(logs.logs.len(), 5);
3160        assert_eq!(logs.logs[0].message, "error 0");
3161        assert_eq!(logs.logs[4].message, "error 4");
3162    }
3163
3164    #[test]
3165    fn test_handle_server_logs_case_insensitive_level() {
3166        use crate::bridge::notifications::LogLevel;
3167
3168        let mut translator = Translator::new();
3169
3170        translator
3171            .notification_cache_mut()
3172            .store_log(LogLevel::Error, "error msg".to_string());
3173
3174        let result = translator.handle_server_logs(10, Some("ERROR".to_string()));
3175        assert!(result.is_ok());
3176
3177        let result = translator.handle_server_logs(10, Some("Error".to_string()));
3178        assert!(result.is_ok());
3179
3180        let result = translator.handle_server_logs(10, Some("eRrOr".to_string()));
3181        assert!(result.is_ok());
3182    }
3183
3184    #[test]
3185    fn test_handle_server_messages_empty() {
3186        let mut translator = Translator::new();
3187
3188        let result = translator.handle_server_messages(10);
3189        assert!(result.is_ok());
3190        let messages = result.unwrap();
3191        assert_eq!(messages.messages.len(), 0);
3192    }
3193
3194    #[test]
3195    fn test_handle_server_messages_with_different_types() {
3196        use crate::bridge::notifications::MessageType;
3197
3198        let mut translator = Translator::new();
3199
3200        translator
3201            .notification_cache_mut()
3202            .store_message(MessageType::Error, "error".to_string());
3203        translator
3204            .notification_cache_mut()
3205            .store_message(MessageType::Warning, "warning".to_string());
3206        translator
3207            .notification_cache_mut()
3208            .store_message(MessageType::Info, "info".to_string());
3209        translator
3210            .notification_cache_mut()
3211            .store_message(MessageType::Log, "log".to_string());
3212
3213        let result = translator.handle_server_messages(10);
3214        assert!(result.is_ok());
3215        let messages = result.unwrap();
3216        assert_eq!(messages.messages.len(), 4);
3217        assert_eq!(messages.messages[0].message, "error");
3218        assert_eq!(messages.messages[1].message, "warning");
3219        assert_eq!(messages.messages[2].message, "info");
3220        assert_eq!(messages.messages[3].message, "log");
3221    }
3222
3223    #[test]
3224    fn test_handle_server_messages_zero_limit() {
3225        use crate::bridge::notifications::MessageType;
3226
3227        let mut translator = Translator::new();
3228
3229        translator
3230            .notification_cache_mut()
3231            .store_message(MessageType::Info, "test".to_string());
3232
3233        let result = translator.handle_server_messages(0);
3234        assert!(result.is_ok());
3235        let messages = result.unwrap();
3236        assert_eq!(messages.messages.len(), 0);
3237    }
3238
3239    #[test]
3240    fn test_handle_cached_diagnostics_path_outside_workspace() {
3241        let mut translator = Translator::new();
3242        let temp_dir1 = TempDir::new().unwrap();
3243        let temp_dir2 = TempDir::new().unwrap();
3244
3245        translator.set_workspace_roots(vec![temp_dir1.path().to_path_buf()]);
3246
3247        let test_file = temp_dir2.path().join("test.rs");
3248        fs::write(&test_file, "fn main() {}").unwrap();
3249
3250        let result = translator.handle_cached_diagnostics(test_file.to_str().unwrap());
3251        assert!(matches!(result, Err(Error::PathOutsideWorkspace(_))));
3252    }
3253
3254    #[test]
3255    fn test_translator_with_custom_extensions() {
3256        let mut extension_map = HashMap::new();
3257        extension_map.insert("nu".to_string(), "nushell".to_string());
3258        extension_map.insert("customext".to_string(), "customlang".to_string());
3259
3260        let translator = Translator::new().with_extensions(extension_map.clone());
3261
3262        assert_eq!(translator.extension_map.len(), 2);
3263        assert_eq!(
3264            translator.extension_map.get("nu"),
3265            Some(&"nushell".to_string())
3266        );
3267        assert_eq!(
3268            translator.extension_map.get("customext"),
3269            Some(&"customlang".to_string())
3270        );
3271    }
3272
3273    #[test]
3274    fn test_get_client_for_file_uses_custom_extension() {
3275        let temp_dir = TempDir::new().unwrap();
3276        let test_file = temp_dir.path().join("script.nu");
3277        fs::write(&test_file, "echo hello").unwrap();
3278
3279        let mut extension_map = HashMap::new();
3280        extension_map.insert("nu".to_string(), "nushell".to_string());
3281
3282        let translator = Translator::new().with_extensions(extension_map);
3283
3284        let result = translator.get_client_for_file(&test_file);
3285
3286        assert!(result.is_err());
3287        if let Err(Error::NoServerForLanguage(lang)) = result {
3288            assert_eq!(lang, "nushell");
3289        } else {
3290            panic!("Expected NoServerForLanguage(nushell) error");
3291        }
3292    }
3293
3294    #[test]
3295    fn test_get_client_for_file_falls_back_to_default() {
3296        let temp_dir = TempDir::new().unwrap();
3297        let test_file = temp_dir.path().join("unknown.xyz");
3298        fs::write(&test_file, "content").unwrap();
3299
3300        let mut extension_map = HashMap::new();
3301        extension_map.insert("rs".to_string(), "rust".to_string());
3302
3303        let translator = Translator::new().with_extensions(extension_map);
3304
3305        let result = translator.get_client_for_file(&test_file);
3306
3307        assert!(result.is_err());
3308        if let Err(Error::NoServerForLanguage(lang)) = result {
3309            assert_eq!(lang, "plaintext");
3310        } else {
3311            panic!("Expected NoServerForLanguage(plaintext) error");
3312        }
3313    }
3314
3315    #[tokio::test]
3316    async fn test_serve_initializes_translator_with_extensions() {
3317        use crate::config::{LanguageExtensionMapping, WorkspaceConfig};
3318
3319        let language_extensions = vec![
3320            LanguageExtensionMapping {
3321                extensions: vec!["nu".to_string()],
3322                language_id: "nushell".to_string(),
3323            },
3324            LanguageExtensionMapping {
3325                extensions: vec!["rs".to_string()],
3326                language_id: "rust".to_string(),
3327            },
3328        ];
3329
3330        let config = crate::config::ServerConfig {
3331            workspace: WorkspaceConfig {
3332                roots: vec![PathBuf::from("/tmp/test-workspace")],
3333                position_encodings: vec!["utf-8".to_string()],
3334                language_extensions: language_extensions.clone(),
3335                heuristics_max_depth: 10,
3336            },
3337            lsp_servers: vec![],
3338        };
3339
3340        let extension_map = config.build_effective_extension_map();
3341        assert_eq!(extension_map.get("nu"), Some(&"nushell".to_string()));
3342        assert_eq!(extension_map.get("rs"), Some(&"rust".to_string()));
3343
3344        // serve() starts in protocol-only mode when no LSP servers are configured;
3345        // it may return a transport error but must not return NoServersAvailable.
3346        let result = crate::serve(config).await;
3347        if let Err(ref err) = result {
3348            assert!(
3349                !matches!(err, crate::error::Error::NoServersAvailable(_)),
3350                "serve() must not return NoServersAvailable for empty lsp_servers config"
3351            );
3352        }
3353    }
3354
3355    #[test]
3356    fn test_convert_call_hierarchy_item_kind_is_numeric() {
3357        let item = lsp_types::CallHierarchyItem {
3358            name: "my_fn".to_string(),
3359            kind: lsp_types::SymbolKind::FUNCTION,
3360            tags: None,
3361            detail: None,
3362            uri: "file:///tmp/test.rs".parse().unwrap(),
3363            range: lsp_types::Range {
3364                start: lsp_types::Position {
3365                    line: 0,
3366                    character: 0,
3367                },
3368                end: lsp_types::Position {
3369                    line: 0,
3370                    character: 5,
3371                },
3372            },
3373            selection_range: lsp_types::Range {
3374                start: lsp_types::Position {
3375                    line: 0,
3376                    character: 0,
3377                },
3378                end: lsp_types::Position {
3379                    line: 0,
3380                    character: 5,
3381                },
3382            },
3383            data: None,
3384        };
3385        let result = convert_call_hierarchy_item(item);
3386        // SymbolKind::FUNCTION is LSP integer 12
3387        assert_eq!(result.kind, 12u32);
3388        assert_eq!(result.name, "my_fn");
3389    }
3390}