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};
5use std::sync::{Arc, Mutex as StdMutex};
6
7use lsp_types::{
8    CallHierarchyIncomingCall, CallHierarchyIncomingCallsParams, CallHierarchyItem,
9    CallHierarchyOutgoingCall, CallHierarchyOutgoingCallsParams,
10    CallHierarchyPrepareParams as LspCallHierarchyPrepareParams, CompletionParams,
11    CompletionTriggerKind, DocumentFormattingParams, DocumentSymbol, DocumentSymbolParams,
12    FormattingOptions, GotoDefinitionParams, Hover, HoverContents, HoverParams as LspHoverParams,
13    InlayHintLabel, InlayHintParams, MarkedString, PartialResultParams, ReferenceContext,
14    ReferenceParams, RenameParams as LspRenameParams,
15    SignatureHelpParams as LspSignatureHelpParams, TextDocumentIdentifier,
16    TextDocumentPositionParams, WorkDoneProgressParams, WorkspaceEdit,
17    WorkspaceSymbolParams as LspWorkspaceSymbolParams,
18};
19use serde::{Deserialize, Serialize};
20use tokio::sync::Mutex;
21use tokio::time::Duration;
22
23use super::state::{ResourceLimits, detect_language, path_to_uri};
24use super::{DiagnosticInfo, DocumentTracker, NotificationCache, lock_std};
25use crate::bridge::encoding::mcp_to_lsp_position;
26use crate::config::{ServerId, ToolKind, ToolRouter, base_language_id};
27use crate::error::{Error, Result};
28use crate::lsp::{LspClient, LspServer};
29
30/// Translator handles MCP tool calls by converting them to LSP requests.
31///
32/// All fields use interior mutability so `Translator` can be shared via a
33/// plain `Arc<Translator>` with no outer lock: every LSP tool call would
34/// otherwise serialize behind a single mutex for its entire round trip
35/// (including the LSP request timeout), which is the root cause fixed here.
36/// Each field is locked independently and only for the short, synchronous
37/// section that touches it. In particular, the actual LSP request/response
38/// round trip (`client.request(...)`) always runs with no lock held.
39///
40/// `document_tracker` is no exception: `DocumentTracker` locks its own state
41/// per-path internally (see its docs), so `prepare_document`'s call into
42/// `ensure_open` never holds a lock shared across unrelated paths or
43/// languages while it does that document's disk I/O and
44/// `textDocument/didOpen`/`didChange` notify.
45#[derive(Debug)]
46pub struct Translator {
47    /// LSP clients indexed by routing identity. Locked only for the map
48    /// lookup/insert itself, never across an LSP request.
49    lsp_clients: Arc<StdMutex<HashMap<ServerId, LspClient>>>,
50    /// LSP servers indexed by routing identity (held for lifetime management).
51    lsp_servers: Arc<StdMutex<HashMap<ServerId, LspServer>>>,
52    /// Document state tracker. Locks its own state internally, per path.
53    document_tracker: Arc<DocumentTracker>,
54    /// Allowed workspace roots for path validation. Read-only after `serve()`
55    /// setup, so no lock is needed.
56    workspace_roots: Arc<Vec<PathBuf>>,
57    /// Custom file extension to language ID mappings. Read-only after
58    /// `serve()` setup, so no lock is needed.
59    extension_map: Arc<HashMap<String, String>>,
60    /// Servers that are configured + applicable but may not have finished
61    /// initializing yet (background init). Used to return a clear "still
62    /// initializing" error instead of "no server configured".
63    expected_servers: Arc<StdMutex<HashSet<ServerId>>>,
64    /// Per-tool routing table: resolves `(language, tool)` to a `ServerId`.
65    /// Locked independently so `rebind_router` (called from a background
66    /// task once registration completes) never contends with an in-flight
67    /// LSP round trip.
68    router: Arc<StdMutex<ToolRouter>>,
69}
70
71impl Translator {
72    /// Create a new translator.
73    ///
74    /// Starts with an empty router: nothing is routable until [`Self::with_router`]
75    /// installs one, which matches having no servers registered.
76    #[must_use]
77    pub fn new() -> Self {
78        Self {
79            lsp_clients: Arc::new(StdMutex::new(HashMap::new())),
80            lsp_servers: Arc::new(StdMutex::new(HashMap::new())),
81            document_tracker: Arc::new(DocumentTracker::new(
82                ResourceLimits::default(),
83                HashMap::new(),
84            )),
85            workspace_roots: Arc::new(Vec::new()),
86            extension_map: Arc::new(HashMap::new()),
87            expected_servers: Arc::new(StdMutex::new(HashSet::new())),
88            router: Arc::new(StdMutex::new(ToolRouter::default())),
89        }
90    }
91
92    /// Set the workspace roots for path validation.
93    ///
94    /// Only called during single-owner setup, before the translator is
95    /// shared, so this replaces the `Arc` wholesale rather than locking.
96    pub fn set_workspace_roots(&mut self, roots: Vec<PathBuf>) {
97        self.workspace_roots = Arc::new(roots);
98    }
99
100    /// Mark the set of servers that are expected (configured + applicable)
101    /// but may still be initializing in the background.
102    pub fn set_expected_servers(&self, servers: HashSet<ServerId>) {
103        *lock_std(&self.expected_servers) = servers;
104    }
105
106    /// Clear the expected-servers set (e.g. after background init failed).
107    pub fn clear_expected_servers(&self) {
108        lock_std(&self.expected_servers).clear();
109    }
110
111    /// Install the per-tool routing table built from the applicable configs.
112    ///
113    /// Only called during single-owner setup, before the translator is
114    /// shared, so this replaces the `Arc`-wrapped router wholesale.
115    #[must_use]
116    pub fn with_router(mut self, router: ToolRouter) -> Self {
117        self.router = Arc::new(StdMutex::new(router));
118        self
119    }
120
121    /// Rebind the routing table to the set of servers that actually
122    /// registered, dropping or redirecting routes to servers that failed to
123    /// spawn. See `ToolRouter::rebind_to_registered` for the full semantics.
124    pub fn rebind_router(&self, registered: &HashSet<ServerId>) {
125        lock_std(&self.router).rebind_to_registered(registered);
126    }
127
128    /// Whether `id` is the server the router currently resolves
129    /// `ToolKind::Diagnostics` to for `language_id`.
130    ///
131    /// Purpose-built for `register_servers`, which needs this to compute the
132    /// diagnostics-cache filter passed into each pump task, without exposing
133    /// the router's lock guard outside this module.
134    #[must_use]
135    pub fn is_diagnostics_route(&self, language_id: &str, id: &ServerId) -> bool {
136        lock_std(&self.router).resolve(language_id, ToolKind::Diagnostics) == Some(id)
137    }
138
139    /// Configure custom file extension mappings.
140    ///
141    /// This method sets the extension map and updates the document tracker
142    /// to use the same mappings for language detection.
143    ///
144    /// Only called during single-owner setup, before the translator is
145    /// shared, so this replaces the `Arc`-wrapped fields wholesale.
146    #[must_use]
147    pub fn with_extensions(mut self, extension_map: HashMap<String, String>) -> Self {
148        self.document_tracker = Arc::new(DocumentTracker::new(
149            ResourceLimits::default(),
150            extension_map.clone(),
151        ));
152        self.extension_map = Arc::new(extension_map);
153        self
154    }
155
156    /// Register an LSP client under its routing identity.
157    // TODO(critic-M1): currently only called once from `register_servers`
158    // during background init, so this can't race a restart. If server
159    // restart/supervision is ever added, the corresponding server's
160    // `document_tracker` state must also be reset here — otherwise
161    // `ensure_open` believes documents are already open on the new process
162    // and sends `didChange` instead of `didOpen`, desyncing the server.
163    pub fn register_client(&self, id: impl Into<ServerId>, client: LspClient) {
164        lock_std(&self.lsp_clients).insert(id.into(), client);
165    }
166
167    /// Register an LSP server under its routing identity.
168    pub fn register_server(&self, id: impl Into<ServerId>, server: LspServer) {
169        lock_std(&self.lsp_servers).insert(id.into(), server);
170    }
171
172    /// Snapshot of currently open document paths, used for MCP resource listing.
173    #[must_use]
174    pub fn open_document_paths(&self) -> Vec<PathBuf> {
175        self.document_tracker.open_paths()
176    }
177
178    /// Whether a document is currently tracked as open.
179    #[must_use]
180    pub fn is_document_open(&self, path: &Path) -> bool {
181        self.document_tracker.is_open(path)
182    }
183
184    // TODO: These methods will be implemented in Phase 3-5
185    // Initialize and shutdown are now handled by LspServer in lifecycle.rs
186
187    // Future implementation will use LspServer instead of LspClient directly
188}
189
190impl Default for Translator {
191    fn default() -> Self {
192        Self::new()
193    }
194}
195
196#[derive(Debug, Serialize)]
197#[serde(rename_all = "camelCase")]
198struct DiagnosticRequestParams {
199    text_document: TextDocumentIdentifier,
200    #[serde(skip_serializing_if = "Option::is_none")]
201    identifier: Option<String>,
202    #[serde(skip_serializing_if = "Option::is_none")]
203    previous_result_id: Option<String>,
204    #[serde(flatten)]
205    work_done_progress_params: WorkDoneProgressParams,
206    #[serde(flatten)]
207    partial_result_params: PartialResultParams,
208}
209
210fn diagnostic_request_params(text_document: TextDocumentIdentifier) -> DiagnosticRequestParams {
211    DiagnosticRequestParams {
212        text_document,
213        identifier: None,
214        previous_result_id: None,
215        work_done_progress_params: WorkDoneProgressParams::default(),
216        partial_result_params: PartialResultParams::default(),
217    }
218}
219
220/// Position in a document (1-based for MCP).
221#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
222pub struct Position2D {
223    /// Line number (1-based).
224    pub line: u32,
225    /// Character offset (1-based).
226    pub character: u32,
227}
228
229/// Range in a document (1-based for MCP).
230#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
231pub struct Range {
232    /// Start position.
233    pub start: Position2D,
234    /// End position.
235    pub end: Position2D,
236}
237
238/// Location in a document.
239#[derive(Debug, Clone, Serialize, Deserialize)]
240pub struct Location {
241    /// URI of the document.
242    pub uri: String,
243    /// Range within the document.
244    pub range: Range,
245}
246
247/// Result of a hover request.
248#[derive(Debug, Clone, Serialize, Deserialize)]
249pub struct HoverResult {
250    /// Hover contents as markdown string.
251    pub contents: String,
252    /// Optional range the hover applies to.
253    pub range: Option<Range>,
254}
255
256/// Result of a definition request.
257#[derive(Debug, Clone, Serialize, Deserialize)]
258pub struct DefinitionResult {
259    /// Locations of the definition.
260    pub locations: Vec<Location>,
261}
262
263/// Result of a references request.
264#[derive(Debug, Clone, Serialize, Deserialize)]
265pub struct ReferencesResult {
266    /// Locations of all references.
267    pub locations: Vec<Location>,
268}
269
270/// Diagnostic severity.
271#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
272#[serde(rename_all = "lowercase")]
273pub enum DiagnosticSeverity {
274    /// Error diagnostic.
275    Error,
276    /// Warning diagnostic.
277    Warning,
278    /// Informational diagnostic.
279    Information,
280    /// Hint diagnostic.
281    Hint,
282}
283
284/// A single diagnostic.
285#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
286pub struct Diagnostic {
287    /// Range where the diagnostic applies.
288    pub range: Range,
289    /// Severity of the diagnostic.
290    pub severity: DiagnosticSeverity,
291    /// Diagnostic message.
292    pub message: String,
293    /// Optional diagnostic code.
294    pub code: Option<String>,
295}
296
297/// Result of a diagnostics request.
298#[derive(Debug, Clone, Serialize, Deserialize)]
299pub struct DiagnosticsResult {
300    /// List of diagnostics for the document.
301    pub diagnostics: Vec<Diagnostic>,
302}
303
304/// A text edit operation.
305#[derive(Debug, Clone, Serialize, Deserialize)]
306pub struct TextEdit {
307    /// Range to replace.
308    pub range: Range,
309    /// New text.
310    pub new_text: String,
311}
312
313/// Changes to a document.
314#[derive(Debug, Clone, Serialize, Deserialize)]
315pub struct DocumentChanges {
316    /// URI of the document.
317    pub uri: String,
318    /// List of edits to apply.
319    pub edits: Vec<TextEdit>,
320}
321
322/// Result of a rename request.
323#[derive(Debug, Clone, Serialize, Deserialize)]
324pub struct RenameResult {
325    /// Changes to apply across documents.
326    pub changes: Vec<DocumentChanges>,
327}
328
329/// A completion item.
330#[derive(Debug, Clone, Serialize, Deserialize)]
331pub struct Completion {
332    /// Label of the completion.
333    pub label: String,
334    /// Kind of completion.
335    pub kind: Option<String>,
336    /// Detail information.
337    pub detail: Option<String>,
338    /// Documentation.
339    pub documentation: Option<String>,
340}
341
342/// Result of a completions request.
343#[derive(Debug, Clone, Serialize, Deserialize)]
344pub struct CompletionsResult {
345    /// List of completion items.
346    pub items: Vec<Completion>,
347}
348
349/// A document symbol.
350#[derive(Debug, Clone, Serialize, Deserialize)]
351pub struct Symbol {
352    /// Name of the symbol.
353    pub name: String,
354    /// Kind of symbol.
355    pub kind: String,
356    /// Range of the symbol.
357    pub range: Range,
358    /// Selection range (identifier location).
359    pub selection_range: Range,
360    /// Child symbols.
361    #[serde(skip_serializing_if = "Option::is_none")]
362    pub children: Option<Vec<Self>>,
363}
364
365/// Result of a document symbols request.
366#[derive(Debug, Clone, Serialize, Deserialize)]
367pub struct DocumentSymbolsResult {
368    /// List of symbols in the document.
369    pub symbols: Vec<Symbol>,
370}
371
372/// Result of a format document request.
373#[derive(Debug, Clone, Serialize, Deserialize)]
374pub struct FormatDocumentResult {
375    /// List of edits to format the document.
376    pub edits: Vec<TextEdit>,
377}
378
379/// A workspace symbol.
380#[derive(Debug, Clone, Serialize, Deserialize)]
381pub struct WorkspaceSymbol {
382    /// Name of the symbol.
383    pub name: String,
384    /// Kind of symbol.
385    pub kind: String,
386    /// Location of the symbol.
387    pub location: Location,
388    /// Optional container name (parent scope).
389    #[serde(skip_serializing_if = "Option::is_none")]
390    pub container_name: Option<String>,
391}
392
393/// Result of workspace symbol search.
394#[derive(Debug, Clone, Serialize, Deserialize)]
395pub struct WorkspaceSymbolResult {
396    /// List of symbols found.
397    pub symbols: Vec<WorkspaceSymbol>,
398}
399
400/// A single code action.
401#[derive(Debug, Clone, Serialize, Deserialize)]
402pub struct CodeAction {
403    /// Title of the code action.
404    pub title: String,
405    /// Kind of code action (quickfix, refactor, etc.).
406    #[serde(skip_serializing_if = "Option::is_none")]
407    pub kind: Option<String>,
408    /// Diagnostics that this action resolves.
409    #[serde(skip_serializing_if = "Vec::is_empty", default)]
410    pub diagnostics: Vec<Diagnostic>,
411    /// Workspace edit to apply.
412    #[serde(skip_serializing_if = "Option::is_none")]
413    pub edit: Option<WorkspaceEditDescription>,
414    /// Command to execute.
415    #[serde(skip_serializing_if = "Option::is_none")]
416    pub command: Option<CommandDescription>,
417    /// Whether this is the preferred action.
418    #[serde(default)]
419    pub is_preferred: bool,
420}
421
422/// Description of a workspace edit.
423#[derive(Debug, Clone, Serialize, Deserialize)]
424pub struct WorkspaceEditDescription {
425    /// Changes to apply to documents.
426    pub changes: Vec<DocumentChanges>,
427}
428
429/// Description of a command.
430#[derive(Debug, Clone, Serialize, Deserialize)]
431pub struct CommandDescription {
432    /// Title of the command.
433    pub title: String,
434    /// Command identifier.
435    pub command: String,
436    /// Command arguments.
437    #[serde(skip_serializing_if = "Vec::is_empty", default)]
438    pub arguments: Vec<serde_json::Value>,
439}
440
441/// Result of code actions request.
442#[derive(Debug, Clone, Serialize, Deserialize)]
443pub struct CodeActionsResult {
444    /// Available code actions.
445    pub actions: Vec<CodeAction>,
446}
447
448/// A call hierarchy item.
449#[derive(Debug, Clone, Serialize, Deserialize)]
450pub struct CallHierarchyItemResult {
451    /// Name of the symbol.
452    pub name: String,
453    /// LSP numeric symbol kind (e.g. 12 for Function).
454    pub kind: u32,
455    /// More detail for this item.
456    #[serde(skip_serializing_if = "Option::is_none")]
457    pub detail: Option<String>,
458    /// URI of the document.
459    pub uri: String,
460    /// Range of the symbol.
461    pub range: Range,
462    /// Selection range (identifier location).
463    ///
464    /// Serialized as `selectionRange` (camelCase) so that the value returned by
465    /// `prepare_call_hierarchy` round-trips correctly when the MCP client passes
466    /// it back to `get_incoming_calls` / `get_outgoing_calls`, which deserialize
467    /// it as `lsp_types::CallHierarchyItem` (camelCase).
468    #[serde(rename = "selectionRange")]
469    pub selection_range: Range,
470    /// Opaque data to pass to incoming/outgoing calls.
471    #[serde(skip_serializing_if = "Option::is_none")]
472    pub data: Option<serde_json::Value>,
473}
474
475/// Result of call hierarchy prepare request.
476#[derive(Debug, Clone, Serialize, Deserialize)]
477pub struct CallHierarchyPrepareResult {
478    /// List of callable items at the position.
479    pub items: Vec<CallHierarchyItemResult>,
480}
481
482/// An incoming call (caller of the current item).
483#[derive(Debug, Clone, Serialize, Deserialize)]
484pub struct IncomingCall {
485    /// The item that calls the current item.
486    pub from: CallHierarchyItemResult,
487    /// Ranges where the call occurs.
488    pub from_ranges: Vec<Range>,
489}
490
491/// Result of incoming calls request.
492#[derive(Debug, Clone, Serialize, Deserialize)]
493pub struct IncomingCallsResult {
494    /// List of incoming calls.
495    pub calls: Vec<IncomingCall>,
496}
497
498/// An outgoing call (callee from the current item).
499#[derive(Debug, Clone, Serialize, Deserialize)]
500pub struct OutgoingCall {
501    /// The item being called.
502    pub to: CallHierarchyItemResult,
503    /// Ranges where the call occurs.
504    pub from_ranges: Vec<Range>,
505}
506
507/// Result of outgoing calls request.
508#[derive(Debug, Clone, Serialize, Deserialize)]
509pub struct OutgoingCallsResult {
510    /// List of outgoing calls.
511    pub calls: Vec<OutgoingCall>,
512}
513
514/// Result of server logs request.
515#[derive(Debug, Clone, Serialize, Deserialize)]
516pub struct ServerLogsResult {
517    /// List of log entries.
518    pub logs: Vec<crate::bridge::notifications::LogEntry>,
519}
520
521/// Result of server messages request.
522#[derive(Debug, Clone, Serialize, Deserialize)]
523pub struct ServerMessagesResult {
524    /// List of server messages.
525    pub messages: Vec<crate::bridge::notifications::ServerMessage>,
526}
527
528/// A single parameter in a signature.
529#[derive(Debug, Clone, Serialize, Deserialize)]
530pub struct SignatureParameter {
531    /// Label of the parameter.
532    pub label: String,
533    /// Optional documentation for the parameter.
534    #[serde(skip_serializing_if = "Option::is_none")]
535    pub documentation: Option<String>,
536}
537
538/// A single signature overload.
539#[derive(Debug, Clone, Serialize, Deserialize)]
540pub struct SignatureInfo {
541    /// Full label of the signature.
542    pub label: String,
543    /// Optional documentation for the signature.
544    #[serde(skip_serializing_if = "Option::is_none")]
545    pub documentation: Option<String>,
546    /// Parameters of the signature.
547    pub parameters: Vec<SignatureParameter>,
548}
549
550/// Result of a signature help request.
551#[derive(Debug, Clone, Serialize, Deserialize)]
552pub struct SignatureHelpResult {
553    /// Available signatures.
554    pub signatures: Vec<SignatureInfo>,
555    /// Index of the active signature.
556    #[serde(skip_serializing_if = "Option::is_none")]
557    pub active_signature: Option<u32>,
558    /// Index of the active parameter within the active signature.
559    #[serde(skip_serializing_if = "Option::is_none")]
560    pub active_parameter: Option<u32>,
561}
562
563/// Result of a go-to-implementation or go-to-type-definition request.
564#[derive(Debug, Clone, Serialize, Deserialize)]
565pub struct LocationsResult {
566    /// Locations found.
567    pub locations: Vec<Location>,
568}
569
570/// A single inlay hint entry.
571#[derive(Debug, Clone, Serialize, Deserialize)]
572pub struct InlayHintEntry {
573    /// Position of the hint (1-based MCP).
574    pub position: Position2D,
575    /// Label text for the hint.
576    pub label: String,
577    /// Hint kind (1 = Type, 2 = Parameter).
578    #[serde(skip_serializing_if = "Option::is_none")]
579    pub kind: Option<u8>,
580    /// Whether to add a space before the hint.
581    #[serde(skip_serializing_if = "Option::is_none")]
582    pub padding_left: Option<bool>,
583    /// Whether to add a space after the hint.
584    #[serde(skip_serializing_if = "Option::is_none")]
585    pub padding_right: Option<bool>,
586    /// Tooltip text.
587    #[serde(skip_serializing_if = "Option::is_none")]
588    pub tooltip: Option<String>,
589}
590
591/// Result of an inlay hints request.
592#[derive(Debug, Clone, Serialize, Deserialize)]
593pub struct InlayHintsResult {
594    /// List of inlay hints.
595    pub hints: Vec<InlayHintEntry>,
596}
597
598/// Maximum allowed position value for validation.
599const MAX_POSITION_VALUE: u32 = 1_000_000;
600/// Maximum allowed range size in lines.
601const MAX_RANGE_LINES: u32 = 10_000;
602
603/// Validate that `path` is within one of `workspace_roots`.
604///
605/// Free function (rather than a `Translator` method) so callers that only need
606/// path validation — e.g. cache-only MCP handlers — can validate against a
607/// cloned, lock-free snapshot of the workspace roots instead of locking the
608/// full `Arc<Mutex<Translator>>`, which may be held elsewhere across a slow
609/// in-flight LSP round-trip.
610///
611/// # Errors
612///
613/// Returns `Error::PathOutsideWorkspace` if the path is outside all workspace roots.
614pub fn validate_path_against_roots(path: &Path, workspace_roots: &[PathBuf]) -> Result<PathBuf> {
615    let canonical = path.canonicalize().map_err(|e| Error::FileIo {
616        path: path.to_path_buf(),
617        source: e,
618    })?;
619
620    // If no workspace roots configured, allow any path (backward compatibility)
621    if workspace_roots.is_empty() {
622        return Ok(canonical);
623    }
624
625    // Check if path is within any workspace root
626    for root in workspace_roots {
627        if let Ok(canonical_root) = root.canonicalize()
628            && canonical.starts_with(&canonical_root)
629        {
630            return Ok(canonical);
631        }
632    }
633
634    Err(Error::PathOutsideWorkspace(path.to_path_buf()))
635}
636
637impl Translator {
638    /// Validate that a path is within allowed workspace boundaries.
639    ///
640    /// # Errors
641    ///
642    /// Returns `Error::PathOutsideWorkspace` if the path is outside all workspace roots.
643    pub(crate) fn validate_path(&self, path: &Path) -> Result<PathBuf> {
644        validate_path_against_roots(path, &self.workspace_roots)
645    }
646
647    /// Resolve the server that should handle `tool` for the file at `path`,
648    /// returning both its routing identity and a cloned client.
649    ///
650    /// Tries the file's detected language first, then (if that has no route)
651    /// its React base language (`.tsx` falling back from `typescriptreact` to
652    /// `typescript`, and similarly for `.jsx`) -- in that order, so an
653    /// explicit `typescriptreact` server still wins over the `typescript`
654    /// fallback when both are configured.
655    ///
656    /// Locks `router`, `lsp_clients`, and (on the not-yet-registered path)
657    /// `expected_servers` only for their respective lookups — every guard is
658    /// dropped before this method returns.
659    fn get_client_for_file(&self, path: &Path, tool: ToolKind) -> Result<(ServerId, LspClient)> {
660        let language = detect_language(path, &self.extension_map);
661        let mut candidates: Vec<&str> = vec![language.as_str()];
662        if let Some(base) = base_language_id(&language) {
663            candidates.push(base);
664        }
665
666        for lang in &candidates {
667            let resolved = lock_std(&self.router).resolve(lang, tool).cloned();
668            let Some(id) = resolved else { continue };
669
670            let found = lock_std(&self.lsp_clients).get(&id).cloned();
671            if let Some(client) = found {
672                return Ok((id, client));
673            }
674            // A route naming a server that is still initializing (e.g. a
675            // large Unity solution loading via OmniSharp) -- tell the caller
676            // to wait and retry rather than implying no server is configured.
677            if lock_std(&self.expected_servers).contains(&id) {
678                return Err(Error::ServerInitializing { server_id: id });
679            }
680            // Unreachable once registration has rebound the router
681            // (`Translator::rebind_router`) -- a route can only name a
682            // registered server after that point. Logged rather than
683            // `debug_assert!`-panicked: this method is reachable by any
684            // library consumer calling `with_router` without registering
685            // matching clients, not just internal misuse.
686            tracing::error!(
687                "router route names server '{id}' for tool '{tool}' that is neither \
688                 registered nor expected"
689            );
690            return Err(Error::NoServerForTool {
691                language_id: (*lang).to_string(),
692                tool,
693            });
694        }
695
696        let has_language = {
697            let router = lock_std(&self.router);
698            candidates.iter().any(|lang| router.has_language(lang))
699        };
700        if has_language {
701            Err(Error::NoServerForTool {
702                language_id: language,
703                tool,
704            })
705        } else {
706            Err(Error::NoServerForLanguage(language))
707        }
708    }
709
710    /// Resolve the LSP client and ensure the document is open.
711    ///
712    /// This is the "prepare" phase shared by every LSP-round-trip handler:
713    /// it validates the path, selects the client via [`Self::get_client_for_file`],
714    /// and calls `ensure_open`, which locks the document tracker's state only
715    /// for the given path. The returned client and URI are owned values, so
716    /// the caller can issue the actual LSP request (the "execute" phase)
717    /// without holding any lock across the network round trip.
718    ///
719    /// `ensure_open`'s own awaits (a `stat`, optionally a re-read of the
720    /// file, and the `textDocument/didOpen`/`didChange` notify) run under a
721    /// lock scoped to `validated_path` alone — see [`DocumentTracker::ensure_open`]
722    /// — so a slow or wedged language server cannot stall `prepare_document`
723    /// calls for unrelated files. (Per-tool routing, #228, means the same
724    /// file can be routed to more than one server; a wedged server-A notify
725    /// still holds this path's lock and can therefore delay a healthy
726    /// server-B call for that *same* file.)
727    async fn prepare_document(
728        &self,
729        file_path: &str,
730        tool: ToolKind,
731    ) -> Result<(LspClient, lsp_types::Uri)> {
732        let path = PathBuf::from(file_path);
733        let validated_path = self.validate_path(&path)?;
734        let (server_id, client) = self.get_client_for_file(&validated_path, tool)?;
735        let uri = self
736            .document_tracker
737            .ensure_open(&validated_path, &server_id, &client)
738            .await?;
739        Ok((client, uri))
740    }
741
742    /// Parse and validate a file URI, returning the validated path.
743    ///
744    /// # Errors
745    ///
746    /// Returns an error if:
747    /// - The URI doesn't have a file:// scheme
748    /// - The path is outside workspace boundaries
749    fn parse_file_uri(&self, uri: &lsp_types::Uri) -> Result<PathBuf> {
750        let uri_str = uri.as_str();
751
752        // Validate file:// scheme
753        if !uri_str.starts_with("file://") {
754            return Err(Error::InvalidToolParams(format!(
755                "Invalid URI scheme, expected file:// but got: {uri_str}"
756            )));
757        }
758
759        // Extract path after file://
760        let path_str = &uri_str["file://".len()..];
761
762        // Handle Windows paths: file:///C:/path -> /C:/path -> C:/path
763        // On Windows, URIs have format file:///C:/path, so we need to strip the leading /
764        #[cfg(windows)]
765        let path_str = if path_str.len() >= 3
766            && path_str.starts_with('/')
767            && path_str.chars().nth(2) == Some(':')
768        {
769            &path_str[1..]
770        } else {
771            path_str
772        };
773
774        let path = PathBuf::from(path_str);
775
776        // Validate path is within workspace
777        self.validate_path(&path)
778    }
779
780    /// Handle hover request.
781    ///
782    /// # Errors
783    ///
784    /// Returns an error if the LSP request fails or the file cannot be opened.
785    pub async fn handle_hover(
786        &self,
787        file_path: String,
788        line: u32,
789        character: u32,
790    ) -> Result<HoverResult> {
791        let (client, uri) = self.prepare_document(&file_path, ToolKind::Hover).await?;
792        let lsp_position = mcp_to_lsp_position(line, character);
793
794        let params = LspHoverParams {
795            text_document_position_params: TextDocumentPositionParams {
796                text_document: TextDocumentIdentifier { uri },
797                position: lsp_position,
798            },
799            work_done_progress_params: WorkDoneProgressParams::default(),
800        };
801
802        let timeout_duration = Duration::from_secs(30);
803        let response: Option<Hover> = client
804            .request("textDocument/hover", params, timeout_duration)
805            .await?;
806
807        let result = match response {
808            Some(hover) => {
809                let contents = extract_hover_contents(hover.contents);
810                let range = hover.range.map(normalize_range);
811                HoverResult { contents, range }
812            }
813            None => HoverResult {
814                contents: "No hover information available".to_string(),
815                range: None,
816            },
817        };
818
819        Ok(result)
820    }
821
822    /// Handle definition request.
823    ///
824    /// # Errors
825    ///
826    /// Returns an error if the LSP request fails or the file cannot be opened.
827    pub async fn handle_definition(
828        &self,
829        file_path: String,
830        line: u32,
831        character: u32,
832    ) -> Result<DefinitionResult> {
833        let (client, uri) = self
834            .prepare_document(&file_path, ToolKind::Definition)
835            .await?;
836        let lsp_position = mcp_to_lsp_position(line, character);
837
838        let params = GotoDefinitionParams {
839            text_document_position_params: TextDocumentPositionParams {
840                text_document: TextDocumentIdentifier { uri },
841                position: lsp_position,
842            },
843            work_done_progress_params: WorkDoneProgressParams::default(),
844            partial_result_params: PartialResultParams::default(),
845        };
846
847        let timeout_duration = Duration::from_secs(30);
848        let response: Option<lsp_types::GotoDefinitionResponse> = client
849            .request("textDocument/definition", params, timeout_duration)
850            .await?;
851
852        let locations = match response {
853            Some(lsp_types::GotoDefinitionResponse::Scalar(loc)) => vec![loc],
854            Some(lsp_types::GotoDefinitionResponse::Array(locs)) => locs,
855            Some(lsp_types::GotoDefinitionResponse::Link(links)) => links
856                .into_iter()
857                .map(|link| lsp_types::Location {
858                    uri: link.target_uri,
859                    range: link.target_selection_range,
860                })
861                .collect(),
862            None => vec![],
863        };
864
865        let result = DefinitionResult {
866            locations: locations
867                .into_iter()
868                .map(|loc| Location {
869                    uri: loc.uri.to_string(),
870                    range: normalize_range(loc.range),
871                })
872                .collect(),
873        };
874
875        Ok(result)
876    }
877
878    /// Handle references request.
879    ///
880    /// # Errors
881    ///
882    /// Returns an error if the LSP request fails or the file cannot be opened.
883    pub async fn handle_references(
884        &self,
885        file_path: String,
886        line: u32,
887        character: u32,
888        include_declaration: bool,
889    ) -> Result<ReferencesResult> {
890        let (client, uri) = self
891            .prepare_document(&file_path, ToolKind::References)
892            .await?;
893        let lsp_position = mcp_to_lsp_position(line, character);
894
895        let params = ReferenceParams {
896            text_document_position: TextDocumentPositionParams {
897                text_document: TextDocumentIdentifier { uri },
898                position: lsp_position,
899            },
900            work_done_progress_params: WorkDoneProgressParams::default(),
901            partial_result_params: PartialResultParams::default(),
902            context: ReferenceContext {
903                include_declaration,
904            },
905        };
906
907        let timeout_duration = Duration::from_secs(30);
908        let response: Option<Vec<lsp_types::Location>> = client
909            .request("textDocument/references", params, timeout_duration)
910            .await?;
911
912        let locations = response.unwrap_or_default();
913
914        let result = ReferencesResult {
915            locations: locations
916                .into_iter()
917                .map(|loc| Location {
918                    uri: loc.uri.to_string(),
919                    range: normalize_range(loc.range),
920                })
921                .collect(),
922        };
923
924        Ok(result)
925    }
926
927    /// Handle diagnostics request.
928    ///
929    /// Merges the LSP pull-model response (`textDocument/diagnostic`) with
930    /// whatever is already cached from `textDocument/publishDiagnostics` push
931    /// notifications for the same file, so this returns the same diagnostics
932    /// `get_cached_diagnostics` would for the file at the same point in time
933    /// (see #244 — rust-analyzer's pull endpoint omits flycheck/clippy-sourced
934    /// diagnostics, and empirically also some native ones, that are only ever
935    /// delivered via the push path). If the pull request itself fails (e.g. a
936    /// push-only server answering `-32601`, or a timeout), a non-empty cache
937    /// entry is returned as a cache-only result instead of propagating the
938    /// error, since the cache is not required to be fresher than the pull
939    /// response to be useful here.
940    ///
941    /// The cache is read only after the pull request settles (success or
942    /// failure) and held only for the lookup itself — never across the LSP
943    /// round-trip — matching the lock-ordering discipline documented on
944    /// `cached_diagnostics_uri`. Like `get_cached_diagnostics`, the cache is
945    /// treated as eventually consistent: a cached entry may reflect a
946    /// slightly older document version than the fresh pull result if an edit
947    /// landed inside the server's flycheck debounce window.
948    ///
949    /// # Errors
950    ///
951    /// Returns an error if the LSP pull request fails and the cache holds no
952    /// diagnostics for the file either, or if the file cannot be opened.
953    pub async fn handle_diagnostics(
954        &self,
955        file_path: String,
956        notification_cache: &Mutex<NotificationCache>,
957    ) -> Result<DiagnosticsResult> {
958        let (client, uri) = self
959            .prepare_document(&file_path, ToolKind::Diagnostics)
960            .await?;
961
962        let params = diagnostic_request_params(TextDocumentIdentifier { uri: uri.clone() });
963
964        let timeout_duration = Duration::from_secs(30);
965        let pull_response: Result<lsp_types::DocumentDiagnosticReportResult> = client
966            .request("textDocument/diagnostic", params, timeout_duration)
967            .await;
968
969        let diag_info = {
970            let cache = notification_cache.lock().await;
971            cache.get_diagnostics(uri.as_str()).cloned()
972        };
973
974        match pull_response {
975            Ok(response) => {
976                let items = match response {
977                    lsp_types::DocumentDiagnosticReportResult::Report(report) => match report {
978                        lsp_types::DocumentDiagnosticReport::Full(full) => {
979                            full.full_document_diagnostic_report.items
980                        }
981                        lsp_types::DocumentDiagnosticReport::Unchanged(_) => vec![],
982                    },
983                    lsp_types::DocumentDiagnosticReportResult::Partial(_) => vec![],
984                };
985                let pull = DiagnosticsResult {
986                    diagnostics: items.iter().map(diagnostic_to_mcp).collect(),
987                };
988                Ok(Self::merge_diagnostics(pull, diag_info.as_ref()))
989            }
990            Err(e) => {
991                let cache_only = Self::diagnostics_from_cache_entry(diag_info.as_ref());
992                if cache_only.diagnostics.is_empty() {
993                    Err(e)
994                } else {
995                    Ok(cache_only)
996                }
997            }
998        }
999    }
1000
1001    /// Handle rename request.
1002    ///
1003    /// # Errors
1004    ///
1005    /// Returns an error if the LSP request fails or the file cannot be opened.
1006    pub async fn handle_rename(
1007        &self,
1008        file_path: String,
1009        line: u32,
1010        character: u32,
1011        new_name: String,
1012    ) -> Result<RenameResult> {
1013        let (client, uri) = self.prepare_document(&file_path, ToolKind::Rename).await?;
1014        let lsp_position = mcp_to_lsp_position(line, character);
1015
1016        let params = LspRenameParams {
1017            text_document_position: TextDocumentPositionParams {
1018                text_document: TextDocumentIdentifier { uri },
1019                position: lsp_position,
1020            },
1021            new_name,
1022            work_done_progress_params: WorkDoneProgressParams::default(),
1023        };
1024
1025        let timeout_duration = Duration::from_secs(30);
1026        let response: Option<WorkspaceEdit> = client
1027            .request("textDocument/rename", params, timeout_duration)
1028            .await?;
1029
1030        let changes = if let Some(edit) = response {
1031            let mut result_changes = Vec::new();
1032
1033            // Prefer the legacy `changes` map (HashMap<Uri, Vec<TextEdit>>).
1034            if let Some(changes_map) = edit.changes {
1035                for (uri, edits) in changes_map {
1036                    result_changes.push(DocumentChanges {
1037                        uri: uri.to_string(),
1038                        edits: edits
1039                            .into_iter()
1040                            .map(|e| TextEdit {
1041                                range: normalize_range(e.range),
1042                                new_text: e.new_text,
1043                            })
1044                            .collect(),
1045                    });
1046                }
1047            }
1048
1049            // Also handle `documentChanges` (array format returned by rust-analyzer).
1050            if result_changes.is_empty() {
1051                let text_doc_edits = match edit.document_changes {
1052                    Some(lsp_types::DocumentChanges::Edits(edits)) => edits,
1053                    Some(lsp_types::DocumentChanges::Operations(ops)) => ops
1054                        .into_iter()
1055                        .filter_map(|op| match op {
1056                            lsp_types::DocumentChangeOperation::Edit(e) => Some(e),
1057                            lsp_types::DocumentChangeOperation::Op(_) => None,
1058                        })
1059                        .collect(),
1060                    None => vec![],
1061                };
1062                for tde in text_doc_edits {
1063                    result_changes.push(DocumentChanges {
1064                        uri: tde.text_document.uri.to_string(),
1065                        edits: tde
1066                            .edits
1067                            .into_iter()
1068                            .map(|one_of| match one_of {
1069                                lsp_types::OneOf::Left(te) => TextEdit {
1070                                    range: normalize_range(te.range),
1071                                    new_text: te.new_text,
1072                                },
1073                                lsp_types::OneOf::Right(ate) => TextEdit {
1074                                    range: normalize_range(ate.text_edit.range),
1075                                    new_text: ate.text_edit.new_text,
1076                                },
1077                            })
1078                            .collect(),
1079                    });
1080                }
1081            }
1082
1083            result_changes
1084        } else {
1085            vec![]
1086        };
1087
1088        Ok(RenameResult { changes })
1089    }
1090
1091    /// Handle completions request.
1092    ///
1093    /// # Errors
1094    ///
1095    /// Returns an error if the LSP request fails or the file cannot be opened.
1096    pub async fn handle_completions(
1097        &self,
1098        file_path: String,
1099        line: u32,
1100        character: u32,
1101        trigger: Option<String>,
1102    ) -> Result<CompletionsResult> {
1103        let (client, uri) = self
1104            .prepare_document(&file_path, ToolKind::Completions)
1105            .await?;
1106        let lsp_position = mcp_to_lsp_position(line, character);
1107
1108        let context = trigger.map(|trigger_char| lsp_types::CompletionContext {
1109            trigger_kind: CompletionTriggerKind::TRIGGER_CHARACTER,
1110            trigger_character: Some(trigger_char),
1111        });
1112
1113        let params = CompletionParams {
1114            text_document_position: TextDocumentPositionParams {
1115                text_document: TextDocumentIdentifier { uri },
1116                position: lsp_position,
1117            },
1118            work_done_progress_params: WorkDoneProgressParams::default(),
1119            partial_result_params: PartialResultParams::default(),
1120            context,
1121        };
1122
1123        let timeout_duration = Duration::from_secs(10);
1124        let response: Option<lsp_types::CompletionResponse> = client
1125            .request("textDocument/completion", params, timeout_duration)
1126            .await?;
1127
1128        let items = match response {
1129            Some(lsp_types::CompletionResponse::Array(items)) => items,
1130            Some(lsp_types::CompletionResponse::List(list)) => list.items,
1131            None => vec![],
1132        };
1133
1134        let result = CompletionsResult {
1135            items: items
1136                .into_iter()
1137                .map(|item| Completion {
1138                    label: item.label,
1139                    kind: item.kind.map(|k| format!("{k:?}")),
1140                    detail: item.detail,
1141                    documentation: item.documentation.map(|doc| match doc {
1142                        lsp_types::Documentation::String(s) => s,
1143                        lsp_types::Documentation::MarkupContent(m) => m.value,
1144                    }),
1145                })
1146                .collect(),
1147        };
1148
1149        Ok(result)
1150    }
1151
1152    /// Handle document symbols request.
1153    ///
1154    /// # Errors
1155    ///
1156    /// Returns an error if the LSP request fails or the file cannot be opened.
1157    pub async fn handle_document_symbols(
1158        &self,
1159        file_path: String,
1160    ) -> Result<DocumentSymbolsResult> {
1161        let (client, uri) = self
1162            .prepare_document(&file_path, ToolKind::DocumentSymbols)
1163            .await?;
1164
1165        let params = DocumentSymbolParams {
1166            text_document: TextDocumentIdentifier { uri },
1167            work_done_progress_params: WorkDoneProgressParams::default(),
1168            partial_result_params: PartialResultParams::default(),
1169        };
1170
1171        let timeout_duration = Duration::from_secs(30);
1172        let response: Option<lsp_types::DocumentSymbolResponse> = client
1173            .request("textDocument/documentSymbol", params, timeout_duration)
1174            .await?;
1175
1176        let symbols = match response {
1177            Some(lsp_types::DocumentSymbolResponse::Flat(symbols)) => symbols
1178                .into_iter()
1179                .map(|sym| Symbol {
1180                    name: sym.name,
1181                    kind: format!("{:?}", sym.kind),
1182                    range: normalize_range(sym.location.range),
1183                    selection_range: normalize_range(sym.location.range),
1184                    children: None,
1185                })
1186                .collect(),
1187            Some(lsp_types::DocumentSymbolResponse::Nested(symbols)) => {
1188                symbols.into_iter().map(convert_document_symbol).collect()
1189            }
1190            None => vec![],
1191        };
1192
1193        Ok(DocumentSymbolsResult { symbols })
1194    }
1195
1196    /// Handle format document request.
1197    ///
1198    /// # Errors
1199    ///
1200    /// Returns an error if the LSP request fails or the file cannot be opened.
1201    pub async fn handle_format_document(
1202        &self,
1203        file_path: String,
1204        tab_size: u32,
1205        insert_spaces: bool,
1206    ) -> Result<FormatDocumentResult> {
1207        let (client, uri) = self
1208            .prepare_document(&file_path, ToolKind::FormatDocument)
1209            .await?;
1210
1211        let params = DocumentFormattingParams {
1212            text_document: TextDocumentIdentifier { uri },
1213            options: FormattingOptions {
1214                tab_size,
1215                insert_spaces,
1216                ..Default::default()
1217            },
1218            work_done_progress_params: WorkDoneProgressParams::default(),
1219        };
1220
1221        let timeout_duration = Duration::from_secs(30);
1222        let response: Option<Vec<lsp_types::TextEdit>> = client
1223            .request("textDocument/formatting", params, timeout_duration)
1224            .await?;
1225
1226        let edits = response.unwrap_or_default();
1227
1228        let result = FormatDocumentResult {
1229            edits: edits
1230                .into_iter()
1231                .map(|edit| TextEdit {
1232                    range: normalize_range(edit.range),
1233                    new_text: edit.new_text,
1234                })
1235                .collect(),
1236        };
1237
1238        Ok(result)
1239    }
1240
1241    /// Handle workspace symbol search.
1242    ///
1243    /// # Errors
1244    ///
1245    /// Returns an error if the LSP request fails or no server is configured.
1246    pub async fn handle_workspace_symbol(
1247        &self,
1248        query: String,
1249        kind_filter: Option<String>,
1250        limit: u32,
1251    ) -> Result<WorkspaceSymbolResult> {
1252        const MAX_QUERY_LENGTH: usize = 1000;
1253        const VALID_SYMBOL_KINDS: &[&str] = &[
1254            "File",
1255            "Module",
1256            "Namespace",
1257            "Package",
1258            "Class",
1259            "Method",
1260            "Property",
1261            "Field",
1262            "Constructor",
1263            "Enum",
1264            "Interface",
1265            "Function",
1266            "Variable",
1267            "Constant",
1268            "String",
1269            "Number",
1270            "Boolean",
1271            "Array",
1272            "Object",
1273            "Key",
1274            "Null",
1275            "EnumMember",
1276            "Struct",
1277            "Event",
1278            "Operator",
1279            "TypeParameter",
1280        ];
1281
1282        // Validate query length
1283        if query.len() > MAX_QUERY_LENGTH {
1284            return Err(Error::InvalidToolParams(format!(
1285                "Query too long: {} chars (max {MAX_QUERY_LENGTH})",
1286                query.len()
1287            )));
1288        }
1289
1290        // Validate kind filter
1291        if let Some(ref kind) = kind_filter
1292            && !VALID_SYMBOL_KINDS
1293                .iter()
1294                .any(|k| k.eq_ignore_ascii_case(kind))
1295        {
1296            return Err(Error::InvalidToolParams(format!(
1297                "Invalid kind_filter: '{kind}'. Valid values: {VALID_SYMBOL_KINDS:?}"
1298            )));
1299        }
1300
1301        // Workspace search has no document, so it resolves via `resolve_any`
1302        // rather than a per-language route. If the resolved server is not
1303        // registered yet but is expected, tell the caller to wait and retry
1304        // rather than implying nothing is configured.
1305        let server_id = lock_std(&self.router)
1306            .resolve_any(ToolKind::WorkspaceSymbols)
1307            .cloned()
1308            .ok_or(Error::NoServerConfigured)?;
1309        let client = lock_std(&self.lsp_clients).get(&server_id).cloned();
1310        let client = client.ok_or_else(|| {
1311            if lock_std(&self.expected_servers).contains(&server_id) {
1312                Error::ServerInitializing { server_id }
1313            } else {
1314                Error::NoServerConfigured
1315            }
1316        })?;
1317
1318        let params = LspWorkspaceSymbolParams {
1319            query,
1320            work_done_progress_params: WorkDoneProgressParams::default(),
1321            partial_result_params: PartialResultParams::default(),
1322        };
1323
1324        let timeout_duration = Duration::from_secs(30);
1325        let response: Option<Vec<lsp_types::SymbolInformation>> = client
1326            .request("workspace/symbol", params, timeout_duration)
1327            .await?;
1328
1329        let mut symbols: Vec<WorkspaceSymbol> = response
1330            .unwrap_or_default()
1331            .into_iter()
1332            .map(|sym| WorkspaceSymbol {
1333                name: sym.name,
1334                kind: format!("{:?}", sym.kind),
1335                location: Location {
1336                    uri: sym.location.uri.to_string(),
1337                    range: normalize_range(sym.location.range),
1338                },
1339                container_name: sym.container_name,
1340            })
1341            .collect();
1342
1343        // Apply kind filter if specified
1344        if let Some(kind) = kind_filter {
1345            symbols.retain(|s| s.kind.eq_ignore_ascii_case(&kind));
1346        }
1347
1348        // Limit results
1349        symbols.truncate(limit as usize);
1350
1351        Ok(WorkspaceSymbolResult { symbols })
1352    }
1353
1354    /// Handle code actions request.
1355    ///
1356    /// # Errors
1357    ///
1358    /// Returns an error if the LSP request fails or the file cannot be opened.
1359    pub async fn handle_code_actions(
1360        &self,
1361        file_path: String,
1362        start_line: u32,
1363        start_character: u32,
1364        end_line: u32,
1365        end_character: u32,
1366        kind_filter: Option<String>,
1367    ) -> Result<CodeActionsResult> {
1368        validate_code_action_params(
1369            start_line,
1370            start_character,
1371            end_line,
1372            end_character,
1373            kind_filter.as_deref(),
1374        )?;
1375
1376        let (client, uri) = self
1377            .prepare_document(&file_path, ToolKind::CodeActions)
1378            .await?;
1379
1380        let range = lsp_types::Range {
1381            start: mcp_to_lsp_position(start_line, start_character),
1382            end: mcp_to_lsp_position(end_line, end_character),
1383        };
1384
1385        // Build context with optional kind filter
1386        let only = kind_filter.map(|k| vec![lsp_types::CodeActionKind::from(k)]);
1387
1388        // Pass empty diagnostics context — rust-analyzer generates code actions
1389        // based on cursor position and its internal analysis state, not on the
1390        // passed diagnostics.  Passing stale cached diagnostics (which may lack
1391        // the internal `data` field ra uses for fix mapping) suppresses results.
1392        let context_diagnostics: Vec<lsp_types::Diagnostic> = vec![];
1393
1394        let params = lsp_types::CodeActionParams {
1395            text_document: TextDocumentIdentifier { uri },
1396            range,
1397            context: lsp_types::CodeActionContext {
1398                diagnostics: context_diagnostics,
1399                only,
1400                trigger_kind: Some(lsp_types::CodeActionTriggerKind::INVOKED),
1401            },
1402            work_done_progress_params: WorkDoneProgressParams::default(),
1403            partial_result_params: PartialResultParams::default(),
1404        };
1405
1406        let timeout_duration = Duration::from_secs(30);
1407        let response: Option<lsp_types::CodeActionResponse> = client
1408            .request("textDocument/codeAction", params, timeout_duration)
1409            .await?;
1410        let response_vec = response.unwrap_or_default();
1411        let mut actions = Vec::with_capacity(response_vec.len());
1412
1413        for action_or_command in response_vec {
1414            let action = match action_or_command {
1415                lsp_types::CodeActionOrCommand::CodeAction(action) => convert_code_action(action),
1416                lsp_types::CodeActionOrCommand::Command(cmd) => {
1417                    let arguments = cmd.arguments.unwrap_or_else(Vec::new);
1418                    CodeAction {
1419                        title: cmd.title.clone(),
1420                        kind: None,
1421                        diagnostics: Vec::new(),
1422                        edit: None,
1423                        command: Some(CommandDescription {
1424                            title: cmd.title,
1425                            command: cmd.command,
1426                            arguments,
1427                        }),
1428                        is_preferred: false,
1429                    }
1430                }
1431            };
1432            actions.push(action);
1433        }
1434
1435        Ok(CodeActionsResult { actions })
1436    }
1437
1438    /// Handle call hierarchy prepare request.
1439    ///
1440    /// # Errors
1441    ///
1442    /// Returns an error if the LSP request fails or the file cannot be opened.
1443    pub async fn handle_call_hierarchy_prepare(
1444        &self,
1445        file_path: String,
1446        line: u32,
1447        character: u32,
1448    ) -> Result<CallHierarchyPrepareResult> {
1449        // Validate position bounds
1450        if line < 1 || character < 1 {
1451            return Err(Error::InvalidToolParams(
1452                "Line and character positions must be >= 1".to_string(),
1453            ));
1454        }
1455
1456        if line > MAX_POSITION_VALUE || character > MAX_POSITION_VALUE {
1457            return Err(Error::InvalidToolParams(format!(
1458                "Position values must be <= {MAX_POSITION_VALUE}"
1459            )));
1460        }
1461
1462        let (client, uri) = self
1463            .prepare_document(&file_path, ToolKind::CallHierarchy)
1464            .await?;
1465        let lsp_position = mcp_to_lsp_position(line, character);
1466
1467        let params = LspCallHierarchyPrepareParams {
1468            text_document_position_params: TextDocumentPositionParams {
1469                text_document: TextDocumentIdentifier { uri },
1470                position: lsp_position,
1471            },
1472            work_done_progress_params: WorkDoneProgressParams::default(),
1473        };
1474
1475        let timeout_duration = Duration::from_secs(30);
1476        let response: Option<Vec<CallHierarchyItem>> = client
1477            .request(
1478                "textDocument/prepareCallHierarchy",
1479                params,
1480                timeout_duration,
1481            )
1482            .await?;
1483
1484        // Pre-allocate and build result
1485        let lsp_items = response.unwrap_or_default();
1486        let mut items = Vec::with_capacity(lsp_items.len());
1487        for item in lsp_items {
1488            items.push(convert_call_hierarchy_item(item));
1489        }
1490
1491        Ok(CallHierarchyPrepareResult { items })
1492    }
1493
1494    /// Handle incoming calls request.
1495    ///
1496    /// # Errors
1497    ///
1498    /// Returns an error if the LSP request fails or the item is invalid.
1499    pub async fn handle_incoming_calls(
1500        &self,
1501        item: serde_json::Value,
1502    ) -> Result<IncomingCallsResult> {
1503        // Deserialize as our own type (1-based coords) then convert to LSP (0-based).
1504        let lsp_item = mcp_item_to_lsp(item)?;
1505
1506        // Parse and validate the URI. Resolved with the same ToolKind as
1507        // `handle_call_hierarchy_prepare` -- the opaque item this call
1508        // receives is only meaningful to the server that produced it, and
1509        // that server is guaranteed to be the same one `prepare` synced the
1510        // document to since both resolve via the same (language, tool) route.
1511        let path = self.parse_file_uri(&lsp_item.uri)?;
1512        let (_server_id, client) = self.get_client_for_file(&path, ToolKind::CallHierarchy)?;
1513
1514        let params = CallHierarchyIncomingCallsParams {
1515            item: lsp_item,
1516            work_done_progress_params: WorkDoneProgressParams::default(),
1517            partial_result_params: PartialResultParams::default(),
1518        };
1519
1520        let timeout_duration = Duration::from_secs(30);
1521        let response: Option<Vec<CallHierarchyIncomingCall>> = client
1522            .request("callHierarchy/incomingCalls", params, timeout_duration)
1523            .await?;
1524
1525        // Pre-allocate and build result
1526        let lsp_calls = response.unwrap_or_default();
1527        let mut calls = Vec::with_capacity(lsp_calls.len());
1528
1529        for call in lsp_calls {
1530            let from_ranges = {
1531                let mut ranges = Vec::with_capacity(call.from_ranges.len());
1532                for range in call.from_ranges {
1533                    ranges.push(normalize_range(range));
1534                }
1535                ranges
1536            };
1537
1538            calls.push(IncomingCall {
1539                from: convert_call_hierarchy_item(call.from),
1540                from_ranges,
1541            });
1542        }
1543
1544        Ok(IncomingCallsResult { calls })
1545    }
1546
1547    /// Handle outgoing calls request.
1548    ///
1549    /// # Errors
1550    ///
1551    /// Returns an error if the LSP request fails or the item is invalid.
1552    pub async fn handle_outgoing_calls(
1553        &self,
1554        item: serde_json::Value,
1555    ) -> Result<OutgoingCallsResult> {
1556        // Deserialize as our own type (1-based coords) then convert to LSP (0-based).
1557        let lsp_item = mcp_item_to_lsp(item)?;
1558
1559        // Parse and validate the URI. Same ToolKind/route as `prepare` and
1560        // `handle_incoming_calls` -- see that function's comment.
1561        let path = self.parse_file_uri(&lsp_item.uri)?;
1562        let (_server_id, client) = self.get_client_for_file(&path, ToolKind::CallHierarchy)?;
1563
1564        let params = CallHierarchyOutgoingCallsParams {
1565            item: lsp_item,
1566            work_done_progress_params: WorkDoneProgressParams::default(),
1567            partial_result_params: PartialResultParams::default(),
1568        };
1569
1570        let timeout_duration = Duration::from_secs(30);
1571        let response: Option<Vec<CallHierarchyOutgoingCall>> = client
1572            .request("callHierarchy/outgoingCalls", params, timeout_duration)
1573            .await?;
1574
1575        // Pre-allocate and build result
1576        let lsp_calls = response.unwrap_or_default();
1577        let mut calls = Vec::with_capacity(lsp_calls.len());
1578
1579        for call in lsp_calls {
1580            let from_ranges = {
1581                let mut ranges = Vec::with_capacity(call.from_ranges.len());
1582                for range in call.from_ranges {
1583                    ranges.push(normalize_range(range));
1584                }
1585                ranges
1586            };
1587
1588            calls.push(OutgoingCall {
1589                to: convert_call_hierarchy_item(call.to),
1590                from_ranges,
1591            });
1592        }
1593
1594        Ok(OutgoingCallsResult { calls })
1595    }
1596
1597    /// Resolve the LSP-side cache key (URI string) for a cached-diagnostics lookup.
1598    ///
1599    /// Split out from the cache read itself so callers (e.g. the
1600    /// `get_cached_diagnostics` MCP tool) can do the path `canonicalize()` and
1601    /// workspace-boundary check *before* taking the `NotificationCache` lock —
1602    /// that lock is also needed by `diagnostics_pump` to store incoming
1603    /// notifications, so nothing that isn't a plain map lookup should run
1604    /// while it's held.
1605    ///
1606    /// # Errors
1607    ///
1608    /// Returns an error if the path is invalid or outside workspace boundaries.
1609    pub fn cached_diagnostics_uri(workspace_roots: &[PathBuf], file_path: &str) -> Result<String> {
1610        let path = PathBuf::from(file_path);
1611        let validated_path = validate_path_against_roots(&path, workspace_roots)?;
1612
1613        // Use path_to_uri (strips \\?\ on Windows) so the key matches what
1614        // rust-analyzer stores in publishDiagnostics notifications.
1615        Ok(path_to_uri(&validated_path).to_string())
1616    }
1617
1618    /// Convert a cached diagnostics entry into the MCP-facing result shape.
1619    ///
1620    /// Takes an already-cloned `Option<&DiagnosticInfo>` (out of the
1621    /// `NotificationCache` lock) rather than the cache itself, so this
1622    /// mapping — which is not a bounded operation for a large diagnostics set
1623    /// — never runs while the cache is locked.
1624    #[must_use]
1625    pub fn diagnostics_from_cache_entry(diag_info: Option<&DiagnosticInfo>) -> DiagnosticsResult {
1626        let diagnostics = diag_info.map_or_else(Vec::new, |diag_info| {
1627            diag_info
1628                .diagnostics
1629                .iter()
1630                .map(diagnostic_to_mcp)
1631                .collect()
1632        });
1633
1634        DiagnosticsResult { diagnostics }
1635    }
1636
1637    /// Merge push-model diagnostics from the notification cache into a
1638    /// pull-model (`textDocument/diagnostic`) result.
1639    ///
1640    /// rust-analyzer's pull endpoint omits diagnostics that are only ever
1641    /// delivered via `textDocument/publishDiagnostics` push notifications —
1642    /// not just flycheck/clippy lints, but empirically (verified against a
1643    /// live rust-analyzer 1.97.1 session, see #244) some native diagnostics
1644    /// too. Those are cached separately in `NotificationCache`.
1645    ///
1646    /// Where the *same* logical problem is reported through both paths, the
1647    /// two representations were observed to differ in both `range` and
1648    /// rendered `message`. Captured example, a "not all trait items
1649    /// implemented" (E0046) error for one `impl` block: pull reported range
1650    /// `(96,7)-(96,12)` (the trait name) with message "not all trait items
1651    /// implemented, missing: `fn hello`"; the push notification for the same
1652    /// error reported range `(95,1)-(95,32)` (the impl block) with message
1653    /// "not all trait items implemented, missing: `hello`\nmissing `hello`
1654    /// in implementation" — same `code`/`severity`, adjacent but distinct
1655    /// ranges, different message text. Exact field equality never dedups
1656    /// cases like that.
1657    ///
1658    /// Given that, a cache entry is treated as a duplicate of a pull entry
1659    /// when both carry a `code`, the `(severity, code)` pair matches, *and*
1660    /// the two ranges are either overlapping or start within
1661    /// `DUPLICATE_RANGE_PROXIMITY_LINES` lines of each other — close
1662    /// enough to be the same underlying model divergence, not two distinct
1663    /// occurrences of the same error class (e.g. two unrelated `E0308`
1664    /// mismatches at different call sites in one file, one caught only
1665    /// natively and one only by flycheck). Diagnostics with no `code` fall
1666    /// back to full-field equality, since there is no cheaper stable
1667    /// identity available for them.
1668    ///
1669    /// Output is sorted by `(start.line, start.character)` so merged
1670    /// cache-only entries don't land out of document order after the
1671    /// pull-model ones.
1672    #[must_use]
1673    pub fn merge_diagnostics(
1674        mut pull: DiagnosticsResult,
1675        diag_info: Option<&DiagnosticInfo>,
1676    ) -> DiagnosticsResult {
1677        /// Start-line distance within which same-code, same-severity
1678        /// diagnostics from the two models are still considered the same
1679        /// underlying problem. Derived from the captured E0046 case above
1680        /// (1 line apart); wide enough to absorb span drift between
1681        /// rust-analyzer's own spans and rustc's, narrow enough that two
1682        /// genuinely distinct same-code errors elsewhere in a file are not
1683        /// collapsed into one.
1684        const DUPLICATE_RANGE_PROXIMITY_LINES: u32 = 3;
1685
1686        fn position_le(a: &Position2D, b: &Position2D) -> bool {
1687            (a.line, a.character) <= (b.line, b.character)
1688        }
1689
1690        fn ranges_close(a: &Range, b: &Range) -> bool {
1691            let overlaps = position_le(&a.start, &b.end) && position_le(&b.start, &a.end);
1692            overlaps || a.start.line.abs_diff(b.start.line) <= DUPLICATE_RANGE_PROXIMITY_LINES
1693        }
1694
1695        fn is_duplicate(pull: &[Diagnostic], candidate: &Diagnostic) -> bool {
1696            pull.iter().any(|p| match (&candidate.code, &p.code) {
1697                (Some(c), Some(pc)) if c == pc && p.severity == candidate.severity => {
1698                    ranges_close(&p.range, &candidate.range)
1699                }
1700                _ => p == candidate,
1701            })
1702        }
1703
1704        let cached = Self::diagnostics_from_cache_entry(diag_info).diagnostics;
1705        let new_diagnostics: Vec<_> = cached
1706            .into_iter()
1707            .filter(|c| !is_duplicate(&pull.diagnostics, c))
1708            .collect();
1709        pull.diagnostics.extend(new_diagnostics);
1710        pull.diagnostics
1711            .sort_by_key(|d| (d.range.start.line, d.range.start.character));
1712        pull
1713    }
1714
1715    /// Handle server logs request.
1716    ///
1717    /// # Errors
1718    ///
1719    /// Returns an error if the `min_level` parameter is invalid.
1720    pub fn handle_server_logs(
1721        cache: &NotificationCache,
1722        limit: usize,
1723        min_level: Option<String>,
1724    ) -> Result<ServerLogsResult> {
1725        use crate::bridge::notifications::LogLevel;
1726
1727        let min_level_filter = if let Some(level_str) = min_level {
1728            let level = match level_str.to_lowercase().as_str() {
1729                "error" => LogLevel::Error,
1730                "warning" => LogLevel::Warning,
1731                "info" => LogLevel::Info,
1732                "debug" => LogLevel::Debug,
1733                _ => {
1734                    return Err(Error::InvalidToolParams(format!(
1735                        "Invalid min_level: '{level_str}'. Valid values: error, warning, info, debug"
1736                    )));
1737                }
1738            };
1739            Some(level)
1740        } else {
1741            None
1742        };
1743
1744        let all_logs = cache.get_logs();
1745
1746        let logs: Vec<_> = all_logs
1747            .iter()
1748            .filter(|log| {
1749                min_level_filter.is_none_or(|min| match min {
1750                    LogLevel::Error => matches!(log.level, LogLevel::Error),
1751                    LogLevel::Warning => matches!(log.level, LogLevel::Error | LogLevel::Warning),
1752                    LogLevel::Info => !matches!(log.level, LogLevel::Debug),
1753                    LogLevel::Debug => true,
1754                })
1755            })
1756            .take(limit)
1757            .cloned()
1758            .collect();
1759
1760        Ok(ServerLogsResult { logs })
1761    }
1762
1763    /// Handle server messages request.
1764    ///
1765    /// # Errors
1766    ///
1767    /// This method does not return errors.
1768    pub fn handle_server_messages(
1769        cache: &NotificationCache,
1770        limit: usize,
1771    ) -> Result<ServerMessagesResult> {
1772        let all_messages = cache.get_messages();
1773        let messages: Vec<_> = all_messages.iter().take(limit).cloned().collect();
1774        Ok(ServerMessagesResult { messages })
1775    }
1776
1777    /// Handle signature help request (`textDocument/signatureHelp`).
1778    ///
1779    /// Returns parameter signatures and documentation while typing a function call.
1780    /// `context` is omitted (None) — the server infers trigger state from position.
1781    ///
1782    /// # Errors
1783    ///
1784    /// Returns an error if the LSP request fails or the file cannot be opened.
1785    pub async fn handle_signature_help(
1786        &self,
1787        file_path: String,
1788        line: u32,
1789        character: u32,
1790    ) -> Result<SignatureHelpResult> {
1791        let (client, uri) = self
1792            .prepare_document(&file_path, ToolKind::SignatureHelp)
1793            .await?;
1794        let lsp_position = mcp_to_lsp_position(line, character);
1795
1796        let params = LspSignatureHelpParams {
1797            text_document_position_params: TextDocumentPositionParams {
1798                text_document: TextDocumentIdentifier { uri },
1799                position: lsp_position,
1800            },
1801            work_done_progress_params: WorkDoneProgressParams::default(),
1802            context: None,
1803        };
1804
1805        let timeout_duration = Duration::from_secs(30);
1806        let response: Option<lsp_types::SignatureHelp> = client
1807            .request("textDocument/signatureHelp", params, timeout_duration)
1808            .await?;
1809
1810        let result = match response {
1811            Some(sig_help) => SignatureHelpResult {
1812                signatures: sig_help
1813                    .signatures
1814                    .into_iter()
1815                    .map(|sig| SignatureInfo {
1816                        label: sig.label,
1817                        documentation: sig.documentation.map(extract_documentation),
1818                        parameters: sig
1819                            .parameters
1820                            .unwrap_or_default()
1821                            .into_iter()
1822                            .map(|p| SignatureParameter {
1823                                label: match p.label {
1824                                    lsp_types::ParameterLabel::Simple(s) => s,
1825                                    lsp_types::ParameterLabel::LabelOffsets([start, end]) => {
1826                                        format!("[{start},{end}]")
1827                                    }
1828                                },
1829                                documentation: p.documentation.map(extract_documentation),
1830                            })
1831                            .collect(),
1832                    })
1833                    .collect(),
1834                active_signature: sig_help.active_signature,
1835                active_parameter: sig_help.active_parameter,
1836            },
1837            None => SignatureHelpResult {
1838                signatures: vec![],
1839                active_signature: None,
1840                active_parameter: None,
1841            },
1842        };
1843
1844        Ok(result)
1845    }
1846
1847    /// Handle go-to-implementation request (`textDocument/implementation`).
1848    ///
1849    /// Returns the locations of trait method or interface member implementations.
1850    ///
1851    /// # Errors
1852    ///
1853    /// Returns an error if the LSP request fails or the file cannot be opened.
1854    pub async fn handle_implementation(
1855        &self,
1856        file_path: String,
1857        line: u32,
1858        character: u32,
1859    ) -> Result<LocationsResult> {
1860        let (client, uri) = self
1861            .prepare_document(&file_path, ToolKind::Implementation)
1862            .await?;
1863        let lsp_position = mcp_to_lsp_position(line, character);
1864
1865        let params = GotoDefinitionParams {
1866            text_document_position_params: TextDocumentPositionParams {
1867                text_document: TextDocumentIdentifier { uri },
1868                position: lsp_position,
1869            },
1870            work_done_progress_params: WorkDoneProgressParams::default(),
1871            partial_result_params: PartialResultParams::default(),
1872        };
1873
1874        let timeout_duration = Duration::from_secs(30);
1875        let response: Option<lsp_types::GotoDefinitionResponse> = client
1876            .request("textDocument/implementation", params, timeout_duration)
1877            .await?;
1878
1879        Ok(LocationsResult {
1880            locations: goto_response_to_locations(response),
1881        })
1882    }
1883
1884    /// Handle go-to-type-definition request (`textDocument/typeDefinition`).
1885    ///
1886    /// Returns the type definition location of the expression at position. Distinct
1887    /// from go-to-definition for variable bindings where definition and type differ.
1888    ///
1889    /// # Errors
1890    ///
1891    /// Returns an error if the LSP request fails or the file cannot be opened.
1892    pub async fn handle_type_definition(
1893        &self,
1894        file_path: String,
1895        line: u32,
1896        character: u32,
1897    ) -> Result<LocationsResult> {
1898        let (client, uri) = self
1899            .prepare_document(&file_path, ToolKind::TypeDefinition)
1900            .await?;
1901        let lsp_position = mcp_to_lsp_position(line, character);
1902
1903        let params = GotoDefinitionParams {
1904            text_document_position_params: TextDocumentPositionParams {
1905                text_document: TextDocumentIdentifier { uri },
1906                position: lsp_position,
1907            },
1908            work_done_progress_params: WorkDoneProgressParams::default(),
1909            partial_result_params: PartialResultParams::default(),
1910        };
1911
1912        let timeout_duration = Duration::from_secs(30);
1913        let response: Option<lsp_types::GotoDefinitionResponse> = client
1914            .request("textDocument/typeDefinition", params, timeout_duration)
1915            .await?;
1916
1917        Ok(LocationsResult {
1918            locations: goto_response_to_locations(response),
1919        })
1920    }
1921
1922    /// Handle inlay hints request (`textDocument/inlayHint`).
1923    ///
1924    /// Returns inferred type and parameter annotations the editor would render inline.
1925    /// Output positions are in MCP 1-based form.
1926    ///
1927    /// # Errors
1928    ///
1929    /// Returns an error if the LSP request fails or the file cannot be opened.
1930    pub async fn handle_inlay_hints(
1931        &self,
1932        file_path: String,
1933        start_line: u32,
1934        start_character: u32,
1935        end_line: u32,
1936        end_character: u32,
1937    ) -> Result<InlayHintsResult> {
1938        use crate::bridge::encoding::lsp_to_mcp_position;
1939
1940        let (client, uri) = self
1941            .prepare_document(&file_path, ToolKind::InlayHints)
1942            .await?;
1943
1944        let lsp_start = mcp_to_lsp_position(start_line, start_character);
1945        let lsp_end = mcp_to_lsp_position(end_line, end_character);
1946
1947        let params = InlayHintParams {
1948            text_document: TextDocumentIdentifier { uri },
1949            range: lsp_types::Range {
1950                start: lsp_start,
1951                end: lsp_end,
1952            },
1953            work_done_progress_params: WorkDoneProgressParams::default(),
1954        };
1955
1956        let timeout_duration = Duration::from_secs(30);
1957        let response: Option<Vec<lsp_types::InlayHint>> = client
1958            .request("textDocument/inlayHint", params, timeout_duration)
1959            .await?;
1960
1961        let hints = response
1962            .unwrap_or_default()
1963            .into_iter()
1964            .map(|hint| {
1965                let (mcp_line, mcp_character) = lsp_to_mcp_position(hint.position);
1966                let label = match hint.label {
1967                    InlayHintLabel::String(s) => s,
1968                    InlayHintLabel::LabelParts(parts) => parts
1969                        .into_iter()
1970                        .map(|p| p.value)
1971                        .collect::<Vec<_>>()
1972                        .concat(),
1973                };
1974                let tooltip = hint.tooltip.map(|t| match t {
1975                    lsp_types::InlayHintTooltip::String(s) => s,
1976                    lsp_types::InlayHintTooltip::MarkupContent(m) => m.value,
1977                });
1978                InlayHintEntry {
1979                    position: Position2D {
1980                        line: mcp_line,
1981                        character: mcp_character,
1982                    },
1983                    label,
1984                    kind: hint.kind.and_then(|k| {
1985                        serde_json::to_value(k)
1986                            .ok()
1987                            .and_then(|v| v.as_i64())
1988                            .and_then(|n| u8::try_from(n).ok())
1989                    }),
1990                    padding_left: hint.padding_left,
1991                    padding_right: hint.padding_right,
1992                    tooltip,
1993                }
1994            })
1995            .collect();
1996
1997        Ok(InlayHintsResult { hints })
1998    }
1999}
2000
2001/// Extract hover contents as markdown string.
2002/// Convert LSP `Documentation` to a plain string.
2003fn extract_documentation(doc: lsp_types::Documentation) -> String {
2004    match doc {
2005        lsp_types::Documentation::String(s) => s,
2006        lsp_types::Documentation::MarkupContent(m) => m.value,
2007    }
2008}
2009
2010/// Normalize a `GotoDefinitionResponse` into a flat list of MCP `Location` values.
2011fn goto_response_to_locations(
2012    response: Option<lsp_types::GotoDefinitionResponse>,
2013) -> Vec<Location> {
2014    let lsp_locs: Vec<lsp_types::Location> = match response {
2015        Some(lsp_types::GotoDefinitionResponse::Scalar(loc)) => vec![loc],
2016        Some(lsp_types::GotoDefinitionResponse::Array(locs)) => locs,
2017        Some(lsp_types::GotoDefinitionResponse::Link(links)) => links
2018            .into_iter()
2019            .map(|link| lsp_types::Location {
2020                uri: link.target_uri,
2021                range: link.target_selection_range,
2022            })
2023            .collect(),
2024        None => vec![],
2025    };
2026
2027    lsp_locs
2028        .into_iter()
2029        .map(|loc| Location {
2030            uri: loc.uri.to_string(),
2031            range: normalize_range(loc.range),
2032        })
2033        .collect()
2034}
2035
2036fn extract_hover_contents(contents: HoverContents) -> String {
2037    match contents {
2038        HoverContents::Scalar(marked_string) => marked_string_to_string(marked_string),
2039        HoverContents::Array(marked_strings) => marked_strings
2040            .into_iter()
2041            .map(marked_string_to_string)
2042            .collect::<Vec<_>>()
2043            .join("\n\n"),
2044        HoverContents::Markup(markup) => markup.value,
2045    }
2046}
2047
2048/// Convert a marked string to a plain string.
2049fn marked_string_to_string(marked: MarkedString) -> String {
2050    match marked {
2051        MarkedString::String(s) => s,
2052        MarkedString::LanguageString(ls) => format!("```{}\n{}\n```", ls.language, ls.value),
2053    }
2054}
2055
2056/// Convert LSP range to MCP range (0-based to 1-based).
2057/// Validate parameters for `handle_code_actions`.
2058fn validate_code_action_params(
2059    start_line: u32,
2060    start_character: u32,
2061    end_line: u32,
2062    end_character: u32,
2063    kind_filter: Option<&str>,
2064) -> Result<()> {
2065    const VALID_ACTION_KINDS: &[&str] = &[
2066        "quickfix",
2067        "refactor",
2068        "refactor.extract",
2069        "refactor.inline",
2070        "refactor.rewrite",
2071        "source",
2072        "source.organizeImports",
2073    ];
2074
2075    if let Some(kind) = kind_filter
2076        && !VALID_ACTION_KINDS
2077            .iter()
2078            .any(|k| k.eq_ignore_ascii_case(kind))
2079    {
2080        return Err(Error::InvalidToolParams(format!(
2081            "Invalid kind_filter: '{kind}'. Valid values: {VALID_ACTION_KINDS:?}"
2082        )));
2083    }
2084
2085    if start_line < 1 || start_character < 1 || end_line < 1 || end_character < 1 {
2086        return Err(Error::InvalidToolParams(
2087            "Line and character positions must be >= 1".to_string(),
2088        ));
2089    }
2090
2091    if start_line > MAX_POSITION_VALUE
2092        || start_character > MAX_POSITION_VALUE
2093        || end_line > MAX_POSITION_VALUE
2094        || end_character > MAX_POSITION_VALUE
2095    {
2096        return Err(Error::InvalidToolParams(format!(
2097            "Position values must be <= {MAX_POSITION_VALUE}"
2098        )));
2099    }
2100
2101    if end_line.saturating_sub(start_line) > MAX_RANGE_LINES {
2102        return Err(Error::InvalidToolParams(format!(
2103            "Range size must be <= {MAX_RANGE_LINES} lines"
2104        )));
2105    }
2106
2107    if start_line > end_line || (start_line == end_line && start_character > end_character) {
2108        return Err(Error::InvalidToolParams(
2109            "Start position must be before or equal to end position".to_string(),
2110        ));
2111    }
2112
2113    Ok(())
2114}
2115
2116/// Convert a `CallHierarchyItemResult` JSON (1-based MCP coordinates) into
2117/// a `lsp_types::CallHierarchyItem` (0-based LSP coordinates).
2118///
2119/// MCP clients receive `CallHierarchyItemResult` from `prepare_call_hierarchy`
2120/// and pass it back opaquely to `get_incoming_calls` / `get_outgoing_calls`.
2121/// The bridge serialises ranges as 1-based; this function inverts that mapping
2122/// before forwarding the item to the LSP server.
2123fn mcp_item_to_lsp(item: serde_json::Value) -> Result<CallHierarchyItem> {
2124    let mcp: CallHierarchyItemResult = serde_json::from_value(item)
2125        .map_err(|e| Error::InvalidToolParams(format!("Invalid call hierarchy item: {e}")))?;
2126
2127    let uri = mcp.uri.parse::<lsp_types::Uri>().map_err(|e| {
2128        Error::InvalidToolParams(format!("Invalid URI in call hierarchy item: {e}"))
2129    })?;
2130
2131    let detail = mcp.detail;
2132    let data = mcp.data;
2133
2134    // Round-trip via serde: `convert_call_hierarchy_item` stored the kind as a u32
2135    // by serialising `SymbolKind`; we reverse this to reconstruct the same value.
2136    let kind: lsp_types::SymbolKind = serde_json::from_value(serde_json::json!(mcp.kind))
2137        .unwrap_or(lsp_types::SymbolKind::FUNCTION);
2138
2139    Ok(CallHierarchyItem {
2140        name: mcp.name,
2141        kind,
2142        tags: None,
2143        detail,
2144        uri,
2145        range: denormalize_range(&mcp.range),
2146        selection_range: denormalize_range(&mcp.selection_range),
2147        data,
2148    })
2149}
2150
2151/// Convert a 1-based MCP range back to a 0-based LSP range.
2152///
2153/// Used when MCP clients pass back a `CallHierarchyItemResult` that was
2154/// previously returned by `prepare_call_hierarchy` (which stores 1-based coords).
2155const fn denormalize_range(range: &Range) -> lsp_types::Range {
2156    lsp_types::Range {
2157        start: lsp_types::Position {
2158            line: range.start.line.saturating_sub(1),
2159            character: range.start.character.saturating_sub(1),
2160        },
2161        end: lsp_types::Position {
2162            line: range.end.line.saturating_sub(1),
2163            character: range.end.character.saturating_sub(1),
2164        },
2165    }
2166}
2167
2168const fn normalize_range(range: lsp_types::Range) -> Range {
2169    Range {
2170        start: Position2D {
2171            line: range.start.line + 1,
2172            character: range.start.character + 1,
2173        },
2174        end: Position2D {
2175            line: range.end.line + 1,
2176            character: range.end.character + 1,
2177        },
2178    }
2179}
2180
2181/// Convert an LSP diagnostic into the MCP-facing `Diagnostic` shape.
2182///
2183/// Shared by both the pull-model (`handle_diagnostics`) and cache-derived
2184/// (`diagnostics_from_cache_entry`) diagnostic paths, so their output never
2185/// diverges in formatting — `merge_diagnostics`'s dedup logic depends on
2186/// both sides mapping severity/code identically.
2187fn diagnostic_to_mcp(diag: &lsp_types::Diagnostic) -> Diagnostic {
2188    Diagnostic {
2189        range: normalize_range(diag.range),
2190        severity: match diag.severity {
2191            Some(lsp_types::DiagnosticSeverity::ERROR) => DiagnosticSeverity::Error,
2192            Some(lsp_types::DiagnosticSeverity::WARNING) => DiagnosticSeverity::Warning,
2193            Some(lsp_types::DiagnosticSeverity::HINT) => DiagnosticSeverity::Hint,
2194            // INFORMATION and None (no severity reported) both fall here.
2195            _ => DiagnosticSeverity::Information,
2196        },
2197        message: diag.message.clone(),
2198        code: diag.code.as_ref().map(|c| match c {
2199            lsp_types::NumberOrString::Number(n) => n.to_string(),
2200            lsp_types::NumberOrString::String(s) => s.clone(),
2201        }),
2202    }
2203}
2204
2205/// Convert LSP document symbol to MCP symbol.
2206fn convert_document_symbol(symbol: DocumentSymbol) -> Symbol {
2207    Symbol {
2208        name: symbol.name,
2209        kind: format!("{:?}", symbol.kind),
2210        range: normalize_range(symbol.range),
2211        selection_range: normalize_range(symbol.selection_range),
2212        children: symbol
2213            .children
2214            .map(|children| children.into_iter().map(convert_document_symbol).collect()),
2215    }
2216}
2217
2218/// Convert LSP call hierarchy item to MCP call hierarchy item.
2219fn convert_call_hierarchy_item(item: CallHierarchyItem) -> CallHierarchyItemResult {
2220    CallHierarchyItemResult {
2221        name: item.name,
2222        kind: serde_json::to_value(item.kind)
2223            .ok()
2224            .and_then(|v| v.as_u64())
2225            .and_then(|n| u32::try_from(n).ok())
2226            .unwrap_or(0),
2227        detail: item.detail,
2228        uri: item.uri.to_string(),
2229        range: normalize_range(item.range),
2230        selection_range: normalize_range(item.selection_range),
2231        data: item.data,
2232    }
2233}
2234
2235/// Convert LSP code action to MCP code action.
2236fn convert_code_action(action: lsp_types::CodeAction) -> CodeAction {
2237    let diagnostics = action.diagnostics.map_or_else(Vec::new, |diags| {
2238        let mut result = Vec::with_capacity(diags.len());
2239        for d in diags {
2240            result.push(Diagnostic {
2241                range: normalize_range(d.range),
2242                severity: match d.severity {
2243                    Some(lsp_types::DiagnosticSeverity::ERROR) => DiagnosticSeverity::Error,
2244                    Some(lsp_types::DiagnosticSeverity::WARNING) => DiagnosticSeverity::Warning,
2245                    Some(lsp_types::DiagnosticSeverity::INFORMATION) => {
2246                        DiagnosticSeverity::Information
2247                    }
2248                    Some(lsp_types::DiagnosticSeverity::HINT) => DiagnosticSeverity::Hint,
2249                    _ => DiagnosticSeverity::Information,
2250                },
2251                message: d.message,
2252                code: d.code.map(|c| match c {
2253                    lsp_types::NumberOrString::Number(n) => n.to_string(),
2254                    lsp_types::NumberOrString::String(s) => s,
2255                }),
2256            });
2257        }
2258        result
2259    });
2260
2261    let edit = action.edit.map(|edit| {
2262        let changes = edit.changes.map_or_else(Vec::new, |changes_map| {
2263            let mut result = Vec::with_capacity(changes_map.len());
2264            for (uri, edits) in changes_map {
2265                let mut text_edits = Vec::with_capacity(edits.len());
2266                for e in edits {
2267                    text_edits.push(TextEdit {
2268                        range: normalize_range(e.range),
2269                        new_text: e.new_text,
2270                    });
2271                }
2272                result.push(DocumentChanges {
2273                    uri: uri.to_string(),
2274                    edits: text_edits,
2275                });
2276            }
2277            result
2278        });
2279        WorkspaceEditDescription { changes }
2280    });
2281
2282    let command = action.command.map(|cmd| {
2283        let arguments = cmd.arguments.unwrap_or_else(Vec::new);
2284        CommandDescription {
2285            title: cmd.title,
2286            command: cmd.command,
2287            arguments,
2288        }
2289    });
2290
2291    CodeAction {
2292        title: action.title,
2293        kind: action.kind.map(|k| k.as_str().to_string()),
2294        diagnostics,
2295        edit,
2296        command,
2297        is_preferred: action.is_preferred.unwrap_or(false),
2298    }
2299}
2300
2301#[cfg(test)]
2302#[allow(clippy::unwrap_used, clippy::expect_used)]
2303mod tests {
2304    use std::fs;
2305
2306    use tempfile::TempDir;
2307    use url::Url;
2308
2309    use super::*;
2310
2311    #[test]
2312    fn test_translator_new() {
2313        let translator = Translator::new();
2314        assert_eq!(translator.workspace_roots.len(), 0);
2315        assert_eq!(lock_std(&translator.lsp_clients).len(), 0);
2316        assert_eq!(lock_std(&translator.lsp_servers).len(), 0);
2317    }
2318
2319    #[test]
2320    fn test_set_workspace_roots() {
2321        let mut translator = Translator::new();
2322        let roots = vec![PathBuf::from("/test/root1"), PathBuf::from("/test/root2")];
2323        translator.set_workspace_roots(roots.clone());
2324        assert_eq!(*translator.workspace_roots, roots);
2325    }
2326
2327    #[test]
2328    fn test_register_server() {
2329        let translator = Translator::new();
2330
2331        // Initial state: no servers registered
2332        assert_eq!(lock_std(&translator.lsp_servers).len(), 0);
2333
2334        // The register_server method exists and is callable
2335        // Full integration testing with real LspServer is done in integration tests
2336        // This unit test verifies the method signature and basic functionality
2337
2338        // Note: We can't easily construct an LspServer in a unit test without async
2339        // and a real LSP server process. The actual registration functionality is
2340        // tested in integration tests (see rust_analyzer_tests.rs).
2341        // This test verifies the data structure is properly initialized.
2342    }
2343
2344    #[test]
2345    fn test_get_client_for_file_server_initializing_when_expected() {
2346        // A configured/applicable language whose LSP client has not registered
2347        // yet (large solution still loading via OmniSharp) must surface
2348        // ServerInitializing — "wait and retry" — not NoServerForLanguage.
2349        let path = PathBuf::from("/ws/Assets/Scripts/Player.cs");
2350        let lang = detect_language(&path, &HashMap::new());
2351        let id = ServerId::from(lang.clone());
2352
2353        let translator = Translator::new().with_router(ToolRouter::catch_all([(id.clone(), lang)]));
2354        let mut expected = HashSet::new();
2355        expected.insert(id.clone());
2356        translator.set_expected_servers(expected);
2357
2358        let err = translator
2359            .get_client_for_file(&path, ToolKind::Hover)
2360            .unwrap_err();
2361        assert!(matches!(err, Error::ServerInitializing { server_id } if server_id == id));
2362    }
2363
2364    #[test]
2365    fn test_get_client_for_file_no_server_when_not_expected() {
2366        // When no route is configured for the language at all, the error
2367        // stays NoServerForLanguage.
2368        let translator = Translator::new();
2369        let path = PathBuf::from("/ws/Assets/Scripts/Player.cs");
2370        let lang = detect_language(&path, &translator.extension_map);
2371
2372        let err = translator
2373            .get_client_for_file(&path, ToolKind::Hover)
2374            .unwrap_err();
2375        assert!(matches!(err, Error::NoServerForLanguage(ref l) if *l == lang));
2376    }
2377
2378    #[test]
2379    fn test_clear_expected_servers_reverts_to_no_server_after_all_routes_dropped() {
2380        // Mirrors the real `serve_with` flow: `rebind_router` (called from
2381        // `register_servers`/the all-failed path) drops routes to servers
2382        // that never registered, then `clear_expected_servers` runs under
2383        // the same lock. Subsequent lookups must fall back to
2384        // NoServerForLanguage rather than keep implying the server is still
2385        // on its way.
2386        let path = PathBuf::from("/ws/Assets/Scripts/Player.cs");
2387        let lang = detect_language(&path, &HashMap::new());
2388        let id = ServerId::from(lang.clone());
2389
2390        let translator = Translator::new().with_router(ToolRouter::catch_all([(id.clone(), lang)]));
2391        let mut expected = HashSet::new();
2392        expected.insert(id);
2393        translator.set_expected_servers(expected);
2394
2395        translator.rebind_router(&HashSet::new());
2396        translator.clear_expected_servers();
2397
2398        let err = translator
2399            .get_client_for_file(&path, ToolKind::Hover)
2400            .unwrap_err();
2401        assert!(matches!(err, Error::NoServerForLanguage(_)));
2402    }
2403
2404    #[test]
2405    fn test_diagnostic_request_params_omit_optional_null_fields() {
2406        let uri = "file:///test.ts".parse().unwrap();
2407        let params = diagnostic_request_params(TextDocumentIdentifier { uri });
2408        let value = serde_json::to_value(params).unwrap();
2409
2410        assert_eq!(value["textDocument"]["uri"], "file:///test.ts");
2411        assert!(value.get("identifier").is_none());
2412        assert!(value.get("previousResultId").is_none());
2413    }
2414
2415    #[test]
2416    fn test_validate_path_no_workspace_roots() {
2417        let translator = Translator::new();
2418        let temp_dir = TempDir::new().unwrap();
2419        let test_file = temp_dir.path().join("test.rs");
2420        fs::write(&test_file, "fn main() {}").unwrap();
2421
2422        // With no workspace roots, any valid path should be accepted
2423        let result = translator.validate_path(&test_file);
2424        assert!(result.is_ok());
2425    }
2426
2427    #[test]
2428    fn test_validate_path_within_workspace() {
2429        let mut translator = Translator::new();
2430        let temp_dir = TempDir::new().unwrap();
2431        let workspace_root = temp_dir.path().to_path_buf();
2432        translator.set_workspace_roots(vec![workspace_root]);
2433
2434        let test_file = temp_dir.path().join("test.rs");
2435        fs::write(&test_file, "fn main() {}").unwrap();
2436
2437        let result = translator.validate_path(&test_file);
2438        assert!(result.is_ok());
2439    }
2440
2441    #[test]
2442    fn test_validate_path_outside_workspace() {
2443        let mut translator = Translator::new();
2444        let temp_dir1 = TempDir::new().unwrap();
2445        let temp_dir2 = TempDir::new().unwrap();
2446
2447        // Set workspace root to temp_dir1
2448        translator.set_workspace_roots(vec![temp_dir1.path().to_path_buf()]);
2449
2450        // Create file in temp_dir2 (outside workspace)
2451        let test_file = temp_dir2.path().join("test.rs");
2452        fs::write(&test_file, "fn main() {}").unwrap();
2453
2454        let result = translator.validate_path(&test_file);
2455        assert!(matches!(result, Err(Error::PathOutsideWorkspace(_))));
2456    }
2457
2458    #[test]
2459    fn test_normalize_range() {
2460        let lsp_range = lsp_types::Range {
2461            start: lsp_types::Position {
2462                line: 0,
2463                character: 0,
2464            },
2465            end: lsp_types::Position {
2466                line: 2,
2467                character: 5,
2468            },
2469        };
2470
2471        let mcp_range = normalize_range(lsp_range);
2472        assert_eq!(mcp_range.start.line, 1);
2473        assert_eq!(mcp_range.start.character, 1);
2474        assert_eq!(mcp_range.end.line, 3);
2475        assert_eq!(mcp_range.end.character, 6);
2476    }
2477
2478    #[test]
2479    fn test_extract_hover_contents_string() {
2480        let marked_string = lsp_types::MarkedString::String("Test hover".to_string());
2481        let contents = lsp_types::HoverContents::Scalar(marked_string);
2482        let result = extract_hover_contents(contents);
2483        assert_eq!(result, "Test hover");
2484    }
2485
2486    #[test]
2487    fn test_extract_hover_contents_language_string() {
2488        let marked_string = lsp_types::MarkedString::LanguageString(lsp_types::LanguageString {
2489            language: "rust".to_string(),
2490            value: "fn main() {}".to_string(),
2491        });
2492        let contents = lsp_types::HoverContents::Scalar(marked_string);
2493        let result = extract_hover_contents(contents);
2494        assert_eq!(result, "```rust\nfn main() {}\n```");
2495    }
2496
2497    #[test]
2498    fn test_extract_hover_contents_markup() {
2499        let markup = lsp_types::MarkupContent {
2500            kind: lsp_types::MarkupKind::Markdown,
2501            value: "# Documentation".to_string(),
2502        };
2503        let contents = lsp_types::HoverContents::Markup(markup);
2504        let result = extract_hover_contents(contents);
2505        assert_eq!(result, "# Documentation");
2506    }
2507
2508    #[tokio::test]
2509    async fn test_handle_workspace_symbol_no_server() {
2510        let translator = Translator::new();
2511        let result = translator
2512            .handle_workspace_symbol("test".to_string(), None, 100)
2513            .await;
2514        assert!(matches!(result, Err(Error::NoServerConfigured)));
2515    }
2516
2517    #[tokio::test]
2518    async fn test_handle_code_actions_invalid_kind() {
2519        let translator = Translator::new();
2520        let result = translator
2521            .handle_code_actions(
2522                "/tmp/test.rs".to_string(),
2523                1,
2524                1,
2525                1,
2526                10,
2527                Some("invalid_kind".to_string()),
2528            )
2529            .await;
2530        assert!(matches!(result, Err(Error::InvalidToolParams(_))));
2531    }
2532
2533    #[tokio::test]
2534    async fn test_handle_code_actions_valid_kind_quickfix() {
2535        use tempfile::TempDir;
2536
2537        let translator = Translator::new();
2538        let temp_dir = TempDir::new().unwrap();
2539        let test_file = temp_dir.path().join("test.rs");
2540        fs::write(&test_file, "fn main() {}").unwrap();
2541
2542        let result = translator
2543            .handle_code_actions(
2544                test_file.to_str().unwrap().to_string(),
2545                1,
2546                1,
2547                1,
2548                10,
2549                Some("quickfix".to_string()),
2550            )
2551            .await;
2552        // Will fail due to no LSP server, but validates kind is accepted
2553        assert!(result.is_err());
2554        assert!(!matches!(result, Err(Error::InvalidToolParams(_))));
2555    }
2556
2557    #[tokio::test]
2558    async fn test_handle_code_actions_valid_kind_refactor() {
2559        use tempfile::TempDir;
2560
2561        let translator = Translator::new();
2562        let temp_dir = TempDir::new().unwrap();
2563        let test_file = temp_dir.path().join("test.rs");
2564        fs::write(&test_file, "fn main() {}").unwrap();
2565
2566        let result = translator
2567            .handle_code_actions(
2568                test_file.to_str().unwrap().to_string(),
2569                1,
2570                1,
2571                1,
2572                10,
2573                Some("refactor".to_string()),
2574            )
2575            .await;
2576        assert!(result.is_err());
2577        assert!(!matches!(result, Err(Error::InvalidToolParams(_))));
2578    }
2579
2580    #[tokio::test]
2581    async fn test_handle_code_actions_valid_kind_refactor_extract() {
2582        use tempfile::TempDir;
2583
2584        let translator = Translator::new();
2585        let temp_dir = TempDir::new().unwrap();
2586        let test_file = temp_dir.path().join("test.rs");
2587        fs::write(&test_file, "fn main() {}").unwrap();
2588
2589        let result = translator
2590            .handle_code_actions(
2591                test_file.to_str().unwrap().to_string(),
2592                1,
2593                1,
2594                1,
2595                10,
2596                Some("refactor.extract".to_string()),
2597            )
2598            .await;
2599        assert!(result.is_err());
2600        assert!(!matches!(result, Err(Error::InvalidToolParams(_))));
2601    }
2602
2603    #[tokio::test]
2604    async fn test_handle_code_actions_valid_kind_source() {
2605        use tempfile::TempDir;
2606
2607        let translator = Translator::new();
2608        let temp_dir = TempDir::new().unwrap();
2609        let test_file = temp_dir.path().join("test.rs");
2610        fs::write(&test_file, "fn main() {}").unwrap();
2611
2612        let result = translator
2613            .handle_code_actions(
2614                test_file.to_str().unwrap().to_string(),
2615                1,
2616                1,
2617                1,
2618                10,
2619                Some("source.organizeImports".to_string()),
2620            )
2621            .await;
2622        assert!(result.is_err());
2623        assert!(!matches!(result, Err(Error::InvalidToolParams(_))));
2624    }
2625
2626    #[tokio::test]
2627    async fn test_handle_code_actions_invalid_range_zero() {
2628        let translator = Translator::new();
2629        let result = translator
2630            .handle_code_actions("/tmp/test.rs".to_string(), 0, 1, 1, 10, None)
2631            .await;
2632        assert!(matches!(result, Err(Error::InvalidToolParams(_))));
2633    }
2634
2635    #[tokio::test]
2636    async fn test_handle_code_actions_invalid_range_order() {
2637        let translator = Translator::new();
2638        let result = translator
2639            .handle_code_actions("/tmp/test.rs".to_string(), 10, 5, 5, 1, None)
2640            .await;
2641        assert!(matches!(result, Err(Error::InvalidToolParams(_))));
2642    }
2643
2644    #[tokio::test]
2645    async fn test_handle_code_actions_empty_range() {
2646        use tempfile::TempDir;
2647
2648        let translator = Translator::new();
2649        let temp_dir = TempDir::new().unwrap();
2650        let test_file = temp_dir.path().join("test.rs");
2651        fs::write(&test_file, "fn main() {}").unwrap();
2652
2653        // Empty range (same position) should be valid
2654        let result = translator
2655            .handle_code_actions(test_file.to_str().unwrap().to_string(), 1, 5, 1, 5, None)
2656            .await;
2657        // Will fail due to no LSP server, but validates range is accepted
2658        assert!(result.is_err());
2659        assert!(!matches!(result, Err(Error::InvalidToolParams(_))));
2660    }
2661
2662    #[test]
2663    fn test_convert_code_action_minimal() {
2664        let lsp_action = lsp_types::CodeAction {
2665            title: "Fix issue".to_string(),
2666            kind: None,
2667            diagnostics: None,
2668            edit: None,
2669            command: None,
2670            is_preferred: None,
2671            disabled: None,
2672            data: None,
2673        };
2674
2675        let result = convert_code_action(lsp_action);
2676        assert_eq!(result.title, "Fix issue");
2677        assert!(result.kind.is_none());
2678        assert!(result.diagnostics.is_empty());
2679        assert!(result.edit.is_none());
2680        assert!(result.command.is_none());
2681        assert!(!result.is_preferred);
2682    }
2683
2684    #[test]
2685    #[allow(clippy::too_many_lines)]
2686    fn test_convert_code_action_with_diagnostics_all_severities() {
2687        let lsp_diagnostics = vec![
2688            lsp_types::Diagnostic {
2689                range: lsp_types::Range {
2690                    start: lsp_types::Position {
2691                        line: 0,
2692                        character: 0,
2693                    },
2694                    end: lsp_types::Position {
2695                        line: 0,
2696                        character: 5,
2697                    },
2698                },
2699                severity: Some(lsp_types::DiagnosticSeverity::ERROR),
2700                message: "Error message".to_string(),
2701                code: Some(lsp_types::NumberOrString::Number(1)),
2702                source: None,
2703                code_description: None,
2704                related_information: None,
2705                tags: None,
2706                data: None,
2707            },
2708            lsp_types::Diagnostic {
2709                range: lsp_types::Range {
2710                    start: lsp_types::Position {
2711                        line: 1,
2712                        character: 0,
2713                    },
2714                    end: lsp_types::Position {
2715                        line: 1,
2716                        character: 5,
2717                    },
2718                },
2719                severity: Some(lsp_types::DiagnosticSeverity::WARNING),
2720                message: "Warning message".to_string(),
2721                code: Some(lsp_types::NumberOrString::String("W001".to_string())),
2722                source: None,
2723                code_description: None,
2724                related_information: None,
2725                tags: None,
2726                data: None,
2727            },
2728            lsp_types::Diagnostic {
2729                range: lsp_types::Range {
2730                    start: lsp_types::Position {
2731                        line: 2,
2732                        character: 0,
2733                    },
2734                    end: lsp_types::Position {
2735                        line: 2,
2736                        character: 5,
2737                    },
2738                },
2739                severity: Some(lsp_types::DiagnosticSeverity::INFORMATION),
2740                message: "Info message".to_string(),
2741                code: None,
2742                source: None,
2743                code_description: None,
2744                related_information: None,
2745                tags: None,
2746                data: None,
2747            },
2748            lsp_types::Diagnostic {
2749                range: lsp_types::Range {
2750                    start: lsp_types::Position {
2751                        line: 3,
2752                        character: 0,
2753                    },
2754                    end: lsp_types::Position {
2755                        line: 3,
2756                        character: 5,
2757                    },
2758                },
2759                severity: Some(lsp_types::DiagnosticSeverity::HINT),
2760                message: "Hint message".to_string(),
2761                code: None,
2762                source: None,
2763                code_description: None,
2764                related_information: None,
2765                tags: None,
2766                data: None,
2767            },
2768        ];
2769
2770        let lsp_action = lsp_types::CodeAction {
2771            title: "Fix all issues".to_string(),
2772            kind: Some(lsp_types::CodeActionKind::QUICKFIX),
2773            diagnostics: Some(lsp_diagnostics),
2774            edit: None,
2775            command: None,
2776            is_preferred: None,
2777            disabled: None,
2778            data: None,
2779        };
2780
2781        let result = convert_code_action(lsp_action);
2782        assert_eq!(result.diagnostics.len(), 4);
2783        assert!(matches!(
2784            result.diagnostics[0].severity,
2785            DiagnosticSeverity::Error
2786        ));
2787        assert!(matches!(
2788            result.diagnostics[1].severity,
2789            DiagnosticSeverity::Warning
2790        ));
2791        assert!(matches!(
2792            result.diagnostics[2].severity,
2793            DiagnosticSeverity::Information
2794        ));
2795        assert!(matches!(
2796            result.diagnostics[3].severity,
2797            DiagnosticSeverity::Hint
2798        ));
2799        assert_eq!(result.diagnostics[0].code, Some("1".to_string()));
2800        assert_eq!(result.diagnostics[1].code, Some("W001".to_string()));
2801    }
2802
2803    #[test]
2804    #[allow(clippy::mutable_key_type)]
2805    fn test_convert_code_action_with_workspace_edit() {
2806        use std::collections::HashMap;
2807        use std::str::FromStr;
2808
2809        let uri = lsp_types::Uri::from_str("file:///test.rs").unwrap();
2810        let mut changes_map = HashMap::new();
2811        changes_map.insert(
2812            uri,
2813            vec![lsp_types::TextEdit {
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                new_text: "fixed".to_string(),
2825            }],
2826        );
2827
2828        let lsp_action = lsp_types::CodeAction {
2829            title: "Apply fix".to_string(),
2830            kind: Some(lsp_types::CodeActionKind::QUICKFIX),
2831            diagnostics: None,
2832            edit: Some(lsp_types::WorkspaceEdit {
2833                changes: Some(changes_map),
2834                document_changes: None,
2835                change_annotations: None,
2836            }),
2837            command: None,
2838            is_preferred: Some(true),
2839            disabled: None,
2840            data: None,
2841        };
2842
2843        let result = convert_code_action(lsp_action);
2844        assert!(result.edit.is_some());
2845        let edit = result.edit.unwrap();
2846        assert_eq!(edit.changes.len(), 1);
2847        assert_eq!(edit.changes[0].uri, "file:///test.rs");
2848        assert_eq!(edit.changes[0].edits.len(), 1);
2849        assert_eq!(edit.changes[0].edits[0].new_text, "fixed");
2850        assert!(result.is_preferred);
2851    }
2852
2853    #[test]
2854    fn test_convert_code_action_with_command() {
2855        let lsp_action = lsp_types::CodeAction {
2856            title: "Run command".to_string(),
2857            kind: Some(lsp_types::CodeActionKind::REFACTOR),
2858            diagnostics: None,
2859            edit: None,
2860            command: Some(lsp_types::Command {
2861                title: "Execute refactor".to_string(),
2862                command: "refactor.extract".to_string(),
2863                arguments: Some(vec![serde_json::json!("arg1"), serde_json::json!(42)]),
2864            }),
2865            is_preferred: None,
2866            disabled: None,
2867            data: None,
2868        };
2869
2870        let result = convert_code_action(lsp_action);
2871        assert!(result.command.is_some());
2872        let cmd = result.command.unwrap();
2873        assert_eq!(cmd.title, "Execute refactor");
2874        assert_eq!(cmd.command, "refactor.extract");
2875        assert_eq!(cmd.arguments.len(), 2);
2876    }
2877
2878    #[tokio::test]
2879    async fn test_handle_call_hierarchy_prepare_invalid_position_zero() {
2880        let translator = Translator::new();
2881        let result = translator
2882            .handle_call_hierarchy_prepare("/tmp/test.rs".to_string(), 0, 1)
2883            .await;
2884        assert!(matches!(result, Err(Error::InvalidToolParams(_))));
2885
2886        let result = translator
2887            .handle_call_hierarchy_prepare("/tmp/test.rs".to_string(), 1, 0)
2888            .await;
2889        assert!(matches!(result, Err(Error::InvalidToolParams(_))));
2890    }
2891
2892    #[tokio::test]
2893    async fn test_handle_call_hierarchy_prepare_invalid_position_too_large() {
2894        let translator = Translator::new();
2895        let result = translator
2896            .handle_call_hierarchy_prepare("/tmp/test.rs".to_string(), 1_000_001, 1)
2897            .await;
2898        assert!(matches!(result, Err(Error::InvalidToolParams(_))));
2899
2900        let result = translator
2901            .handle_call_hierarchy_prepare("/tmp/test.rs".to_string(), 1, 1_000_001)
2902            .await;
2903        assert!(matches!(result, Err(Error::InvalidToolParams(_))));
2904    }
2905
2906    #[tokio::test]
2907    async fn test_handle_incoming_calls_invalid_json() {
2908        let translator = Translator::new();
2909        let invalid_item = serde_json::json!({"invalid": "structure"});
2910        let result = translator.handle_incoming_calls(invalid_item).await;
2911        assert!(matches!(result, Err(Error::InvalidToolParams(_))));
2912    }
2913
2914    #[tokio::test]
2915    async fn test_handle_outgoing_calls_invalid_json() {
2916        let translator = Translator::new();
2917        let invalid_item = serde_json::json!({"invalid": "structure"});
2918        let result = translator.handle_outgoing_calls(invalid_item).await;
2919        assert!(matches!(result, Err(Error::InvalidToolParams(_))));
2920    }
2921
2922    #[tokio::test]
2923    async fn test_parse_file_uri_invalid_scheme() {
2924        let translator = Translator::new();
2925        let uri: lsp_types::Uri = "http://example.com/file.rs".parse().unwrap();
2926        let result = translator.parse_file_uri(&uri);
2927        assert!(matches!(result, Err(Error::InvalidToolParams(_))));
2928    }
2929
2930    #[tokio::test]
2931    async fn test_parse_file_uri_valid_scheme() {
2932        let translator = Translator::new();
2933        let temp_dir = TempDir::new().unwrap();
2934        let test_file = temp_dir.path().join("test.rs");
2935        fs::write(&test_file, "fn main() {}").unwrap();
2936
2937        // Use url crate for cross-platform file URI creation
2938        let file_url = Url::from_file_path(&test_file).unwrap();
2939        let uri: lsp_types::Uri = file_url.as_str().parse().unwrap();
2940        let result = translator.parse_file_uri(&uri);
2941        assert!(result.is_ok());
2942    }
2943
2944    #[test]
2945    fn test_handle_cached_diagnostics_empty() {
2946        let cache = NotificationCache::new();
2947        let temp_dir = TempDir::new().unwrap();
2948        let test_file = temp_dir.path().join("test.rs");
2949        fs::write(&test_file, "fn main() {}").unwrap();
2950
2951        let cache_key =
2952            Translator::cached_diagnostics_uri(&[], test_file.to_str().unwrap()).unwrap();
2953        let diag_info = cache.get_diagnostics(&cache_key).cloned();
2954        let diags = Translator::diagnostics_from_cache_entry(diag_info.as_ref());
2955        assert_eq!(diags.diagnostics.len(), 0);
2956    }
2957
2958    #[test]
2959    fn test_handle_server_logs_with_filter() {
2960        use crate::bridge::notifications::LogLevel;
2961
2962        let mut cache = NotificationCache::new();
2963
2964        // Add some logs
2965        cache.store_log(LogLevel::Error, "error msg".to_string());
2966        cache.store_log(LogLevel::Warning, "warning msg".to_string());
2967        cache.store_log(LogLevel::Info, "info msg".to_string());
2968        cache.store_log(LogLevel::Debug, "debug msg".to_string());
2969
2970        // Test with error filter
2971        let result = Translator::handle_server_logs(&cache, 10, Some("error".to_string()));
2972        assert!(result.is_ok());
2973        let logs = result.unwrap();
2974        assert_eq!(logs.logs.len(), 1);
2975        assert_eq!(logs.logs[0].message, "error msg");
2976
2977        // Test with warning filter (includes error and warning)
2978        let result = Translator::handle_server_logs(&cache, 10, Some("warning".to_string()));
2979        assert!(result.is_ok());
2980        let logs = result.unwrap();
2981        assert_eq!(logs.logs.len(), 2);
2982
2983        // Test with info filter (excludes debug)
2984        let result = Translator::handle_server_logs(&cache, 10, Some("info".to_string()));
2985        assert!(result.is_ok());
2986        let logs = result.unwrap();
2987        assert_eq!(logs.logs.len(), 3);
2988
2989        // Test with debug filter (includes all)
2990        let result = Translator::handle_server_logs(&cache, 10, Some("debug".to_string()));
2991        assert!(result.is_ok());
2992        let logs = result.unwrap();
2993        assert_eq!(logs.logs.len(), 4);
2994
2995        // Test with invalid filter
2996        let result = Translator::handle_server_logs(&cache, 10, Some("invalid".to_string()));
2997        assert!(matches!(result, Err(Error::InvalidToolParams(_))));
2998    }
2999
3000    #[test]
3001    fn test_handle_server_messages_limit() {
3002        use crate::bridge::notifications::MessageType;
3003
3004        let mut cache = NotificationCache::new();
3005
3006        // Add some messages
3007        for i in 0..10 {
3008            cache.store_message(MessageType::Info, format!("message {i}"));
3009        }
3010
3011        // Test limit
3012        let result = Translator::handle_server_messages(&cache, 5);
3013        assert!(result.is_ok());
3014        let messages = result.unwrap();
3015        assert_eq!(messages.messages.len(), 5);
3016        assert_eq!(messages.messages[0].message, "message 0");
3017        assert_eq!(messages.messages[4].message, "message 4");
3018
3019        // Test limit larger than available
3020        let result = Translator::handle_server_messages(&cache, 100);
3021        assert!(result.is_ok());
3022        let messages = result.unwrap();
3023        assert_eq!(messages.messages.len(), 10);
3024    }
3025
3026    #[test]
3027    fn test_handle_cached_diagnostics_with_data() {
3028        let mut cache = NotificationCache::new();
3029        let temp_dir = TempDir::new().unwrap();
3030        let test_file = temp_dir.path().join("test.rs");
3031        fs::write(&test_file, "fn main() {}").unwrap();
3032
3033        let canonical_path = test_file.canonicalize().unwrap();
3034        let uri: lsp_types::Uri = Url::from_file_path(&canonical_path)
3035            .unwrap()
3036            .as_str()
3037            .parse()
3038            .unwrap();
3039        let diagnostic = lsp_types::Diagnostic {
3040            range: lsp_types::Range {
3041                start: lsp_types::Position {
3042                    line: 0,
3043                    character: 0,
3044                },
3045                end: lsp_types::Position {
3046                    line: 0,
3047                    character: 5,
3048                },
3049            },
3050            severity: Some(lsp_types::DiagnosticSeverity::ERROR),
3051            message: "test error".to_string(),
3052            code: Some(lsp_types::NumberOrString::String("E001".to_string())),
3053            source: None,
3054            code_description: None,
3055            related_information: None,
3056            tags: None,
3057            data: None,
3058        };
3059
3060        cache.store_diagnostics(&uri, Some(1), vec![diagnostic]);
3061
3062        let cache_key =
3063            Translator::cached_diagnostics_uri(&[], test_file.to_str().unwrap()).unwrap();
3064        let diag_info = cache.get_diagnostics(&cache_key).cloned();
3065        let diags = Translator::diagnostics_from_cache_entry(diag_info.as_ref());
3066        assert_eq!(diags.diagnostics.len(), 1);
3067        assert_eq!(diags.diagnostics[0].message, "test error");
3068        assert_eq!(diags.diagnostics[0].code, Some("E001".to_string()));
3069        assert!(matches!(
3070            diags.diagnostics[0].severity,
3071            DiagnosticSeverity::Error
3072        ));
3073        assert_eq!(diags.diagnostics[0].range.start.line, 1);
3074        assert_eq!(diags.diagnostics[0].range.start.character, 1);
3075    }
3076
3077    #[test]
3078    #[allow(clippy::too_many_lines)]
3079    fn test_handle_cached_diagnostics_multiple_severities() {
3080        let mut cache = NotificationCache::new();
3081        let temp_dir = TempDir::new().unwrap();
3082        let test_file = temp_dir.path().join("test.rs");
3083        fs::write(&test_file, "fn main() {}").unwrap();
3084
3085        let canonical_path = test_file.canonicalize().unwrap();
3086        let uri: lsp_types::Uri = Url::from_file_path(&canonical_path)
3087            .unwrap()
3088            .as_str()
3089            .parse()
3090            .unwrap();
3091        let diagnostics = vec![
3092            lsp_types::Diagnostic {
3093                range: lsp_types::Range {
3094                    start: lsp_types::Position {
3095                        line: 0,
3096                        character: 0,
3097                    },
3098                    end: lsp_types::Position {
3099                        line: 0,
3100                        character: 5,
3101                    },
3102                },
3103                severity: Some(lsp_types::DiagnosticSeverity::ERROR),
3104                message: "error".to_string(),
3105                code: None,
3106                source: None,
3107                code_description: None,
3108                related_information: None,
3109                tags: None,
3110                data: None,
3111            },
3112            lsp_types::Diagnostic {
3113                range: lsp_types::Range {
3114                    start: lsp_types::Position {
3115                        line: 1,
3116                        character: 0,
3117                    },
3118                    end: lsp_types::Position {
3119                        line: 1,
3120                        character: 5,
3121                    },
3122                },
3123                severity: Some(lsp_types::DiagnosticSeverity::WARNING),
3124                message: "warning".to_string(),
3125                code: None,
3126                source: None,
3127                code_description: None,
3128                related_information: None,
3129                tags: None,
3130                data: None,
3131            },
3132            lsp_types::Diagnostic {
3133                range: lsp_types::Range {
3134                    start: lsp_types::Position {
3135                        line: 2,
3136                        character: 0,
3137                    },
3138                    end: lsp_types::Position {
3139                        line: 2,
3140                        character: 5,
3141                    },
3142                },
3143                severity: Some(lsp_types::DiagnosticSeverity::INFORMATION),
3144                message: "info".to_string(),
3145                code: None,
3146                source: None,
3147                code_description: None,
3148                related_information: None,
3149                tags: None,
3150                data: None,
3151            },
3152            lsp_types::Diagnostic {
3153                range: lsp_types::Range {
3154                    start: lsp_types::Position {
3155                        line: 3,
3156                        character: 0,
3157                    },
3158                    end: lsp_types::Position {
3159                        line: 3,
3160                        character: 5,
3161                    },
3162                },
3163                severity: Some(lsp_types::DiagnosticSeverity::HINT),
3164                message: "hint".to_string(),
3165                code: None,
3166                source: None,
3167                code_description: None,
3168                related_information: None,
3169                tags: None,
3170                data: None,
3171            },
3172        ];
3173
3174        cache.store_diagnostics(&uri, Some(1), diagnostics);
3175
3176        let cache_key =
3177            Translator::cached_diagnostics_uri(&[], test_file.to_str().unwrap()).unwrap();
3178        let diag_info = cache.get_diagnostics(&cache_key).cloned();
3179        let diags = Translator::diagnostics_from_cache_entry(diag_info.as_ref());
3180        assert_eq!(diags.diagnostics.len(), 4);
3181        assert!(matches!(
3182            diags.diagnostics[0].severity,
3183            DiagnosticSeverity::Error
3184        ));
3185        assert!(matches!(
3186            diags.diagnostics[1].severity,
3187            DiagnosticSeverity::Warning
3188        ));
3189        assert!(matches!(
3190            diags.diagnostics[2].severity,
3191            DiagnosticSeverity::Information
3192        ));
3193        assert!(matches!(
3194            diags.diagnostics[3].severity,
3195            DiagnosticSeverity::Hint
3196        ));
3197    }
3198
3199    #[test]
3200    fn test_handle_cached_diagnostics_with_numeric_code() {
3201        let mut cache = NotificationCache::new();
3202        let temp_dir = TempDir::new().unwrap();
3203        let test_file = temp_dir.path().join("test.rs");
3204        fs::write(&test_file, "fn main() {}").unwrap();
3205
3206        let canonical_path = test_file.canonicalize().unwrap();
3207        let uri: lsp_types::Uri = Url::from_file_path(&canonical_path)
3208            .unwrap()
3209            .as_str()
3210            .parse()
3211            .unwrap();
3212        let diagnostic = lsp_types::Diagnostic {
3213            range: lsp_types::Range {
3214                start: lsp_types::Position {
3215                    line: 0,
3216                    character: 0,
3217                },
3218                end: lsp_types::Position {
3219                    line: 0,
3220                    character: 5,
3221                },
3222            },
3223            severity: Some(lsp_types::DiagnosticSeverity::ERROR),
3224            message: "test error".to_string(),
3225            code: Some(lsp_types::NumberOrString::Number(42)),
3226            source: None,
3227            code_description: None,
3228            related_information: None,
3229            tags: None,
3230            data: None,
3231        };
3232
3233        cache.store_diagnostics(&uri, Some(1), vec![diagnostic]);
3234
3235        let cache_key =
3236            Translator::cached_diagnostics_uri(&[], test_file.to_str().unwrap()).unwrap();
3237        let diag_info = cache.get_diagnostics(&cache_key).cloned();
3238        let diags = Translator::diagnostics_from_cache_entry(diag_info.as_ref());
3239        assert_eq!(diags.diagnostics.len(), 1);
3240        assert_eq!(diags.diagnostics[0].code, Some("42".to_string()));
3241    }
3242
3243    #[test]
3244    fn test_handle_cached_diagnostics_invalid_path() {
3245        let result = Translator::cached_diagnostics_uri(&[], "/nonexistent/path/file.rs");
3246        assert!(matches!(result, Err(Error::FileIo { .. })));
3247    }
3248
3249    /// Builds an LSP-side diagnostic for `merge_diagnostics` cache fixtures.
3250    fn lsp_diag(
3251        line: u32,
3252        end_character: u32,
3253        severity: lsp_types::DiagnosticSeverity,
3254        message: &str,
3255        code: Option<&str>,
3256    ) -> lsp_types::Diagnostic {
3257        lsp_types::Diagnostic {
3258            range: lsp_types::Range {
3259                start: lsp_types::Position { line, character: 0 },
3260                end: lsp_types::Position {
3261                    line,
3262                    character: end_character,
3263                },
3264            },
3265            severity: Some(severity),
3266            message: message.to_string(),
3267            code: code.map(|c| lsp_types::NumberOrString::String(c.to_string())),
3268            source: None,
3269            code_description: None,
3270            related_information: None,
3271            tags: None,
3272            data: None,
3273        }
3274    }
3275
3276    fn diag_info(diagnostics: Vec<lsp_types::Diagnostic>) -> DiagnosticInfo {
3277        DiagnosticInfo {
3278            uri: "file:///test.rs".parse().unwrap(),
3279            version: Some(1),
3280            diagnostics,
3281        }
3282    }
3283
3284    #[test]
3285    fn test_merge_diagnostics_cache_only_appends_to_empty_pull() {
3286        let pull = DiagnosticsResult {
3287            diagnostics: vec![],
3288        };
3289        let cache = diag_info(vec![lsp_diag(
3290            0,
3291            10,
3292            lsp_types::DiagnosticSeverity::WARNING,
3293            "unused import: `std::fmt`",
3294            None,
3295        )]);
3296
3297        let merged = Translator::merge_diagnostics(pull, Some(&cache));
3298
3299        assert_eq!(merged.diagnostics.len(), 1);
3300        assert_eq!(merged.diagnostics[0].message, "unused import: `std::fmt`");
3301        assert!(matches!(
3302            merged.diagnostics[0].severity,
3303            DiagnosticSeverity::Warning
3304        ));
3305    }
3306
3307    #[test]
3308    fn test_merge_diagnostics_exact_duplicate_not_repeated() {
3309        // Same range/severity/message/code as the cache entry below, expressed
3310        // in the 1-based MCP shape `diagnostics_from_cache_entry` would produce.
3311        let pull_diag = Diagnostic {
3312            range: Range {
3313                start: Position2D {
3314                    line: 1,
3315                    character: 1,
3316                },
3317                end: Position2D {
3318                    line: 1,
3319                    character: 11,
3320                },
3321            },
3322            severity: DiagnosticSeverity::Error,
3323            message: "mismatched types".to_string(),
3324            code: Some("E0308".to_string()),
3325        };
3326        let pull = DiagnosticsResult {
3327            diagnostics: vec![pull_diag.clone()],
3328        };
3329        let cache = diag_info(vec![lsp_diag(
3330            0,
3331            10,
3332            lsp_types::DiagnosticSeverity::ERROR,
3333            "mismatched types",
3334            Some("E0308"),
3335        )]);
3336
3337        let merged = Translator::merge_diagnostics(pull, Some(&cache));
3338
3339        assert_eq!(merged.diagnostics.len(), 1);
3340        assert_eq!(merged.diagnostics[0], pull_diag);
3341    }
3342
3343    #[test]
3344    fn test_merge_diagnostics_no_cache_entry_returns_pull_unchanged() {
3345        let pull_diag = Diagnostic {
3346            range: Range {
3347                start: Position2D {
3348                    line: 1,
3349                    character: 1,
3350                },
3351                end: Position2D {
3352                    line: 1,
3353                    character: 5,
3354                },
3355            },
3356            severity: DiagnosticSeverity::Error,
3357            message: "syntax error".to_string(),
3358            code: None,
3359        };
3360        let pull = DiagnosticsResult {
3361            diagnostics: vec![pull_diag.clone()],
3362        };
3363
3364        let merged = Translator::merge_diagnostics(pull, None);
3365
3366        assert_eq!(merged.diagnostics, vec![pull_diag]);
3367    }
3368
3369    #[test]
3370    fn test_merge_diagnostics_multiple_distinct_cache_entries_all_appear() {
3371        let pull = DiagnosticsResult {
3372            diagnostics: vec![],
3373        };
3374        let cache = diag_info(vec![
3375            lsp_diag(
3376                0,
3377                10,
3378                lsp_types::DiagnosticSeverity::WARNING,
3379                "unused import: `std::fmt`",
3380                None,
3381            ),
3382            lsp_diag(
3383                5,
3384                8,
3385                lsp_types::DiagnosticSeverity::WARNING,
3386                "function `helper` is never used",
3387                None,
3388            ),
3389        ]);
3390
3391        let merged = Translator::merge_diagnostics(pull, Some(&cache));
3392
3393        assert_eq!(merged.diagnostics.len(), 2);
3394        assert!(
3395            merged
3396                .diagnostics
3397                .iter()
3398                .any(|d| d.message == "unused import: `std::fmt`")
3399        );
3400        assert!(
3401            merged
3402                .diagnostics
3403                .iter()
3404                .any(|d| d.message == "function `helper` is never used")
3405        );
3406    }
3407
3408    #[test]
3409    fn test_merge_diagnostics_same_range_different_message_not_deduped() {
3410        let pull_diag = Diagnostic {
3411            range: Range {
3412                start: Position2D {
3413                    line: 1,
3414                    character: 1,
3415                },
3416                end: Position2D {
3417                    line: 1,
3418                    character: 11,
3419                },
3420            },
3421            severity: DiagnosticSeverity::Error,
3422            message: "mismatched types".to_string(),
3423            code: None,
3424        };
3425        let pull = DiagnosticsResult {
3426            diagnostics: vec![pull_diag],
3427        };
3428        // Same range and severity as the pull diagnostic, but a different
3429        // message — must be treated as a distinct diagnostic, not a duplicate.
3430        let cache = diag_info(vec![lsp_diag(
3431            0,
3432            10,
3433            lsp_types::DiagnosticSeverity::ERROR,
3434            "expected `i32`, found `&str`",
3435            None,
3436        )]);
3437
3438        let merged = Translator::merge_diagnostics(pull, Some(&cache));
3439
3440        assert_eq!(merged.diagnostics.len(), 2);
3441    }
3442
3443    /// Pins a cross-model duplicate shape verified empirically against a live
3444    /// rust-analyzer 1.97.1 session (#244): the pull and push diagnostics for
3445    /// the *same* "not all trait items implemented" (E0046) error had
3446    /// different ranges (trait name vs. impl block) and different messages
3447    /// (terse vs. rustc's full rendering), but shared `code` and `severity`.
3448    /// Exact-field dedup would report this twice; the `(severity, code)`
3449    /// fingerprint must collapse it to one entry.
3450    #[test]
3451    fn test_merge_diagnostics_same_code_different_range_and_message_deduped() {
3452        let pull_diag = Diagnostic {
3453            range: Range {
3454                start: Position2D {
3455                    line: 96,
3456                    character: 7,
3457                },
3458                end: Position2D {
3459                    line: 96,
3460                    character: 12,
3461                },
3462            },
3463            severity: DiagnosticSeverity::Error,
3464            message: "not all trait items implemented, missing: `fn hello`".to_string(),
3465            code: Some("E0046".to_string()),
3466        };
3467        let pull = DiagnosticsResult {
3468            diagnostics: vec![pull_diag.clone()],
3469        };
3470        // Same code and severity, but a different range and a longer,
3471        // differently-worded message -- the rustc-rendered push side of the
3472        // same underlying error.
3473        let cache = diag_info(vec![lsp_diag(
3474            94,
3475            31,
3476            lsp_types::DiagnosticSeverity::ERROR,
3477            "not all trait items implemented, missing: `hello`\nmissing `hello` in implementation",
3478            Some("E0046"),
3479        )]);
3480
3481        let merged = Translator::merge_diagnostics(pull, Some(&cache));
3482
3483        assert_eq!(merged.diagnostics.len(), 1);
3484        assert_eq!(merged.diagnostics[0], pull_diag);
3485    }
3486
3487    /// Regression: `merge_diagnostics`'s `(severity, code)` fingerprint alone
3488    /// is coarser than full-field equality and cannot tell apart two
3489    /// genuinely distinct diagnostics that happen to share `code` and
3490    /// `severity` -- e.g. two separate `E0308` mismatched-type errors at
3491    /// different locations in the same file, one caught only by native
3492    /// (pull) analysis and a second, unrelated one caught only by
3493    /// flycheck/cargo check (cache), such as an error inside macro-expanded
3494    /// code the native pass did not evaluate. This previously caused the
3495    /// cache-only entry to be silently dropped -- reproducing #244's exact
3496    /// failure mode, just relocated from "no merge" to "over-eager dedup".
3497    ///
3498    /// The range-proximity check on `is_duplicate` (see `merge_diagnostics`)
3499    /// closes this: these two diagnostics are 45 lines apart, far outside
3500    /// `DUPLICATE_RANGE_PROXIMITY_LINES`, so both must survive the merge.
3501    #[test]
3502    fn test_merge_diagnostics_same_code_distinct_diagnostics_at_different_locations_both_kept() {
3503        let pull_diag = Diagnostic {
3504            range: Range {
3505                start: Position2D {
3506                    line: 5,
3507                    character: 9,
3508                },
3509                end: Position2D {
3510                    line: 5,
3511                    character: 20,
3512                },
3513            },
3514            severity: DiagnosticSeverity::Error,
3515            message: "mismatched types: expected `i32`, found `&str`".to_string(),
3516            code: Some("E0308".to_string()),
3517        };
3518        let pull = DiagnosticsResult {
3519            diagnostics: vec![pull_diag.clone()],
3520        };
3521        // A second, unrelated E0308 at a completely different location with
3522        // a completely different message -- a real, distinct diagnostic,
3523        // not a duplicate of pull_diag.
3524        let cache = diag_info(vec![lsp_diag(
3525            49,
3526            22,
3527            lsp_types::DiagnosticSeverity::ERROR,
3528            "mismatched types: expected `String`, found `Vec<u8>`",
3529            Some("E0308"),
3530        )]);
3531
3532        let merged = Translator::merge_diagnostics(pull, Some(&cache));
3533
3534        assert_eq!(merged.diagnostics.len(), 2);
3535        assert_eq!(merged.diagnostics[0], pull_diag);
3536        assert_eq!(
3537            merged.diagnostics[1].message,
3538            "mismatched types: expected `String`, found `Vec<u8>`"
3539        );
3540    }
3541
3542    #[test]
3543    fn test_handle_server_logs_no_filter() {
3544        use crate::bridge::notifications::LogLevel;
3545
3546        let mut cache = NotificationCache::new();
3547
3548        cache.store_log(LogLevel::Error, "error msg".to_string());
3549        cache.store_log(LogLevel::Warning, "warning msg".to_string());
3550        cache.store_log(LogLevel::Info, "info msg".to_string());
3551        cache.store_log(LogLevel::Debug, "debug msg".to_string());
3552
3553        let result = Translator::handle_server_logs(&cache, 10, None);
3554        assert!(result.is_ok());
3555        let logs = result.unwrap();
3556        assert_eq!(logs.logs.len(), 4);
3557    }
3558
3559    #[test]
3560    fn test_handle_server_logs_error_filter_strict() {
3561        use crate::bridge::notifications::LogLevel;
3562
3563        let mut cache = NotificationCache::new();
3564
3565        cache.store_log(LogLevel::Error, "error msg".to_string());
3566        cache.store_log(LogLevel::Warning, "warning msg".to_string());
3567        cache.store_log(LogLevel::Info, "info msg".to_string());
3568
3569        let result = Translator::handle_server_logs(&cache, 10, Some("error".to_string()));
3570        assert!(result.is_ok());
3571        let logs = result.unwrap();
3572        assert_eq!(logs.logs.len(), 1);
3573        assert_eq!(logs.logs[0].message, "error msg");
3574    }
3575
3576    #[test]
3577    fn test_handle_server_logs_warning_filter_includes_errors() {
3578        use crate::bridge::notifications::LogLevel;
3579
3580        let mut cache = NotificationCache::new();
3581
3582        cache.store_log(LogLevel::Error, "error msg".to_string());
3583        cache.store_log(LogLevel::Warning, "warning msg".to_string());
3584        cache.store_log(LogLevel::Info, "info msg".to_string());
3585
3586        let result = Translator::handle_server_logs(&cache, 10, Some("warning".to_string()));
3587        assert!(result.is_ok());
3588        let logs = result.unwrap();
3589        assert_eq!(logs.logs.len(), 2);
3590    }
3591
3592    #[test]
3593    fn test_handle_server_logs_info_filter_excludes_debug() {
3594        use crate::bridge::notifications::LogLevel;
3595
3596        let mut cache = NotificationCache::new();
3597
3598        cache.store_log(LogLevel::Error, "error msg".to_string());
3599        cache.store_log(LogLevel::Info, "info msg".to_string());
3600        cache.store_log(LogLevel::Debug, "debug msg".to_string());
3601
3602        let result = Translator::handle_server_logs(&cache, 10, Some("info".to_string()));
3603        assert!(result.is_ok());
3604        let logs = result.unwrap();
3605        assert_eq!(logs.logs.len(), 2);
3606    }
3607
3608    #[test]
3609    fn test_handle_server_logs_debug_filter_includes_all() {
3610        use crate::bridge::notifications::LogLevel;
3611
3612        let mut cache = NotificationCache::new();
3613
3614        cache.store_log(LogLevel::Error, "error msg".to_string());
3615        cache.store_log(LogLevel::Warning, "warning msg".to_string());
3616        cache.store_log(LogLevel::Info, "info msg".to_string());
3617        cache.store_log(LogLevel::Debug, "debug msg".to_string());
3618
3619        let result = Translator::handle_server_logs(&cache, 10, Some("debug".to_string()));
3620        assert!(result.is_ok());
3621        let logs = result.unwrap();
3622        assert_eq!(logs.logs.len(), 4);
3623    }
3624
3625    #[test]
3626    fn test_handle_server_logs_limit_applies_after_filter() {
3627        use crate::bridge::notifications::LogLevel;
3628
3629        let mut cache = NotificationCache::new();
3630
3631        for i in 0..10 {
3632            cache.store_log(LogLevel::Error, format!("error {i}"));
3633        }
3634
3635        let result = Translator::handle_server_logs(&cache, 5, Some("error".to_string()));
3636        assert!(result.is_ok());
3637        let logs = result.unwrap();
3638        assert_eq!(logs.logs.len(), 5);
3639        assert_eq!(logs.logs[0].message, "error 0");
3640        assert_eq!(logs.logs[4].message, "error 4");
3641    }
3642
3643    #[test]
3644    fn test_handle_server_logs_case_insensitive_level() {
3645        use crate::bridge::notifications::LogLevel;
3646
3647        let mut cache = NotificationCache::new();
3648
3649        cache.store_log(LogLevel::Error, "error msg".to_string());
3650
3651        let result = Translator::handle_server_logs(&cache, 10, Some("ERROR".to_string()));
3652        assert!(result.is_ok());
3653
3654        let result = Translator::handle_server_logs(&cache, 10, Some("Error".to_string()));
3655        assert!(result.is_ok());
3656
3657        let result = Translator::handle_server_logs(&cache, 10, Some("eRrOr".to_string()));
3658        assert!(result.is_ok());
3659    }
3660
3661    #[test]
3662    fn test_handle_server_messages_empty() {
3663        let cache = NotificationCache::new();
3664
3665        let result = Translator::handle_server_messages(&cache, 10);
3666        assert!(result.is_ok());
3667        let messages = result.unwrap();
3668        assert_eq!(messages.messages.len(), 0);
3669    }
3670
3671    #[test]
3672    fn test_handle_server_messages_with_different_types() {
3673        use crate::bridge::notifications::MessageType;
3674
3675        let mut cache = NotificationCache::new();
3676
3677        cache.store_message(MessageType::Error, "error".to_string());
3678        cache.store_message(MessageType::Warning, "warning".to_string());
3679        cache.store_message(MessageType::Info, "info".to_string());
3680        cache.store_message(MessageType::Log, "log".to_string());
3681
3682        let result = Translator::handle_server_messages(&cache, 10);
3683        assert!(result.is_ok());
3684        let messages = result.unwrap();
3685        assert_eq!(messages.messages.len(), 4);
3686        assert_eq!(messages.messages[0].message, "error");
3687        assert_eq!(messages.messages[1].message, "warning");
3688        assert_eq!(messages.messages[2].message, "info");
3689        assert_eq!(messages.messages[3].message, "log");
3690    }
3691
3692    #[test]
3693    fn test_handle_server_messages_zero_limit() {
3694        use crate::bridge::notifications::MessageType;
3695
3696        let mut cache = NotificationCache::new();
3697
3698        cache.store_message(MessageType::Info, "test".to_string());
3699
3700        let result = Translator::handle_server_messages(&cache, 0);
3701        assert!(result.is_ok());
3702        let messages = result.unwrap();
3703        assert_eq!(messages.messages.len(), 0);
3704    }
3705
3706    #[test]
3707    fn test_handle_cached_diagnostics_path_outside_workspace() {
3708        let temp_dir1 = TempDir::new().unwrap();
3709        let temp_dir2 = TempDir::new().unwrap();
3710
3711        let workspace_roots = vec![temp_dir1.path().to_path_buf()];
3712
3713        let test_file = temp_dir2.path().join("test.rs");
3714        fs::write(&test_file, "fn main() {}").unwrap();
3715
3716        let result =
3717            Translator::cached_diagnostics_uri(&workspace_roots, test_file.to_str().unwrap());
3718        assert!(matches!(result, Err(Error::PathOutsideWorkspace(_))));
3719    }
3720
3721    #[test]
3722    fn test_translator_with_custom_extensions() {
3723        let mut extension_map = HashMap::new();
3724        extension_map.insert("nu".to_string(), "nushell".to_string());
3725        extension_map.insert("customext".to_string(), "customlang".to_string());
3726
3727        let translator = Translator::new().with_extensions(extension_map.clone());
3728
3729        assert_eq!(translator.extension_map.len(), 2);
3730        assert_eq!(
3731            translator.extension_map.get("nu"),
3732            Some(&"nushell".to_string())
3733        );
3734        assert_eq!(
3735            translator.extension_map.get("customext"),
3736            Some(&"customlang".to_string())
3737        );
3738    }
3739
3740    #[test]
3741    fn test_get_client_for_file_uses_custom_extension() {
3742        let temp_dir = TempDir::new().unwrap();
3743        let test_file = temp_dir.path().join("script.nu");
3744        fs::write(&test_file, "echo hello").unwrap();
3745
3746        let mut extension_map = HashMap::new();
3747        extension_map.insert("nu".to_string(), "nushell".to_string());
3748
3749        let translator = Translator::new().with_extensions(extension_map);
3750
3751        let result = translator.get_client_for_file(&test_file, ToolKind::Hover);
3752
3753        assert!(result.is_err());
3754        if let Err(Error::NoServerForLanguage(lang)) = result {
3755            assert_eq!(lang, "nushell");
3756        } else {
3757            panic!("Expected NoServerForLanguage(nushell) error");
3758        }
3759    }
3760
3761    #[test]
3762    fn test_get_client_for_file_falls_back_to_default() {
3763        let temp_dir = TempDir::new().unwrap();
3764        let test_file = temp_dir.path().join("unknown.xyz");
3765        fs::write(&test_file, "content").unwrap();
3766
3767        let mut extension_map = HashMap::new();
3768        extension_map.insert("rs".to_string(), "rust".to_string());
3769
3770        let translator = Translator::new().with_extensions(extension_map);
3771
3772        let result = translator.get_client_for_file(&test_file, ToolKind::Hover);
3773
3774        assert!(result.is_err());
3775        if let Err(Error::NoServerForLanguage(lang)) = result {
3776            assert_eq!(lang, "plaintext");
3777        } else {
3778            panic!("Expected NoServerForLanguage(plaintext) error");
3779        }
3780    }
3781
3782    #[test]
3783    fn test_get_client_for_file_routes_tsx_to_typescript_server() {
3784        let temp_dir = TempDir::new().unwrap();
3785        let test_file = temp_dir.path().join("component.tsx");
3786        fs::write(&test_file, "export const Component = () => <div />").unwrap();
3787
3788        let mut extension_map = HashMap::new();
3789        extension_map.insert("tsx".to_string(), "typescriptreact".to_string());
3790
3791        let translator = Translator::new()
3792            .with_extensions(extension_map)
3793            .with_router(ToolRouter::catch_all([(
3794                ServerId::from("typescript"),
3795                "typescript".to_string(),
3796            )]));
3797        translator.register_client(
3798            "typescript".to_string(),
3799            LspClient::new(crate::config::LspServerConfig::typescript()),
3800        );
3801
3802        let (_id, client) = translator
3803            .get_client_for_file(&test_file, ToolKind::Hover)
3804            .unwrap();
3805        assert_eq!(client.language_id(), "typescript");
3806    }
3807
3808    #[test]
3809    fn test_get_client_for_file_prefers_exact_react_server() {
3810        let temp_dir = TempDir::new().unwrap();
3811        let test_file = temp_dir.path().join("component.tsx");
3812        fs::write(&test_file, "export const Component = () => <div />").unwrap();
3813
3814        let mut extension_map = HashMap::new();
3815        extension_map.insert("tsx".to_string(), "typescriptreact".to_string());
3816
3817        let typescript_react_config = crate::config::LspServerConfig {
3818            language_id: "typescriptreact".to_string(),
3819            command: "typescript-language-server".to_string(),
3820            args: vec!["--stdio".to_string()],
3821            env: HashMap::new(),
3822            file_patterns: vec!["**/*.tsx".to_string()],
3823            initialization_options: None,
3824            timeout_seconds: 30,
3825            heuristics: None,
3826            name: None,
3827            handles: None,
3828        };
3829
3830        let translator = Translator::new()
3831            .with_extensions(extension_map)
3832            .with_router(ToolRouter::catch_all([
3833                (ServerId::from("typescript"), "typescript".to_string()),
3834                (
3835                    ServerId::from("typescriptreact"),
3836                    "typescriptreact".to_string(),
3837                ),
3838            ]));
3839        translator.register_client(
3840            "typescript".to_string(),
3841            LspClient::new(crate::config::LspServerConfig::typescript()),
3842        );
3843        translator.register_client(
3844            "typescriptreact".to_string(),
3845            LspClient::new(typescript_react_config),
3846        );
3847
3848        let (_id, client) = translator
3849            .get_client_for_file(&test_file, ToolKind::Hover)
3850            .unwrap();
3851        assert_eq!(client.language_id(), "typescriptreact");
3852    }
3853
3854    #[test]
3855    fn test_get_client_for_file_routes_jsx_to_javascript_server() {
3856        let temp_dir = TempDir::new().unwrap();
3857        let test_file = temp_dir.path().join("component.jsx");
3858        fs::write(&test_file, "export const Component = () => <div />").unwrap();
3859
3860        let mut extension_map = HashMap::new();
3861        extension_map.insert("jsx".to_string(), "javascriptreact".to_string());
3862
3863        let javascript_config = crate::config::LspServerConfig {
3864            language_id: "javascript".to_string(),
3865            command: "typescript-language-server".to_string(),
3866            args: vec!["--stdio".to_string()],
3867            env: HashMap::new(),
3868            file_patterns: vec!["**/*.js".to_string(), "**/*.jsx".to_string()],
3869            initialization_options: None,
3870            timeout_seconds: 30,
3871            heuristics: None,
3872            name: None,
3873            handles: None,
3874        };
3875        let translator = Translator::new()
3876            .with_extensions(extension_map)
3877            .with_router(ToolRouter::catch_all([(
3878                ServerId::from("javascript"),
3879                "javascript".to_string(),
3880            )]));
3881        translator.register_client("javascript".to_string(), LspClient::new(javascript_config));
3882
3883        let (_id, client) = translator
3884            .get_client_for_file(&test_file, ToolKind::Hover)
3885            .unwrap();
3886        assert_eq!(client.language_id(), "javascript");
3887    }
3888
3889    #[tokio::test]
3890    async fn test_serve_initializes_translator_with_extensions() {
3891        use crate::config::{LanguageExtensionMapping, WorkspaceConfig};
3892
3893        let language_extensions = vec![
3894            LanguageExtensionMapping {
3895                extensions: vec!["nu".to_string()],
3896                language_id: "nushell".to_string(),
3897            },
3898            LanguageExtensionMapping {
3899                extensions: vec!["rs".to_string()],
3900                language_id: "rust".to_string(),
3901            },
3902        ];
3903
3904        let config = crate::config::ServerConfig {
3905            workspace: WorkspaceConfig {
3906                roots: vec![PathBuf::from("/tmp/test-workspace")],
3907                position_encodings: vec!["utf-8".to_string()],
3908                language_extensions: language_extensions.clone(),
3909                heuristics_max_depth: 10,
3910            },
3911            lsp_servers: vec![],
3912        };
3913
3914        let extension_map = config.build_effective_extension_map();
3915        assert_eq!(extension_map.get("nu"), Some(&"nushell".to_string()));
3916        assert_eq!(extension_map.get("rs"), Some(&"rust".to_string()));
3917
3918        // serve() starts in protocol-only mode when no LSP servers are configured;
3919        // it may return a transport error but must not return NoServersAvailable.
3920        let result = crate::serve(config).await;
3921        if let Err(ref err) = result {
3922            assert!(
3923                !matches!(err, crate::error::Error::NoServersAvailable(_)),
3924                "serve() must not return NoServersAvailable for empty lsp_servers config"
3925            );
3926        }
3927    }
3928
3929    #[test]
3930    fn test_convert_call_hierarchy_item_kind_is_numeric() {
3931        let item = lsp_types::CallHierarchyItem {
3932            name: "my_fn".to_string(),
3933            kind: lsp_types::SymbolKind::FUNCTION,
3934            tags: None,
3935            detail: None,
3936            uri: "file:///tmp/test.rs".parse().unwrap(),
3937            range: lsp_types::Range {
3938                start: lsp_types::Position {
3939                    line: 0,
3940                    character: 0,
3941                },
3942                end: lsp_types::Position {
3943                    line: 0,
3944                    character: 5,
3945                },
3946            },
3947            selection_range: lsp_types::Range {
3948                start: lsp_types::Position {
3949                    line: 0,
3950                    character: 0,
3951                },
3952                end: lsp_types::Position {
3953                    line: 0,
3954                    character: 5,
3955                },
3956            },
3957            data: None,
3958        };
3959        let result = convert_call_hierarchy_item(item);
3960        // SymbolKind::FUNCTION is LSP integer 12
3961        assert_eq!(result.kind, 12u32);
3962        assert_eq!(result.name, "my_fn");
3963    }
3964
3965    // ------------------------------------------------------------------
3966    // Lock-latency regression tests (#108, #159)
3967    // ------------------------------------------------------------------
3968    //
3969    // These use two `cat` child processes as a fake LSP transport, the same
3970    // technique as `bridge::state::tests::fake_lsp_client` (duplicated here
3971    // since that helper is private to its own test module): `cat` on the
3972    // "write" half echoes back whatever mcpls sends it, letting a test read
3973    // outbound requests/notifications off `write_stdout`; `cat` on the "read"
3974    // half relays whatever a test writes to `read_half_stdin` back to the
3975    // client as if it came from a real server, letting a test fabricate
3976    // responses with controlled timing.
3977
3978    use std::process::Stdio;
3979
3980    use serde_json::Value as JsonValue;
3981    use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
3982    use tokio::process::{Child, ChildStdin, ChildStdout, Command};
3983    use tokio::time::timeout;
3984
3985    use crate::config::LspServerConfig;
3986    use crate::lsp::LspTransport;
3987
3988    struct FakeServer {
3989        _write_half: Child,
3990        _read_half: Child,
3991        read_half_stdin: ChildStdin,
3992        write_stdout: ChildStdout,
3993    }
3994
3995    fn fake_lsp_client() -> (LspClient, FakeServer) {
3996        let mut write_half = Command::new("cat")
3997            .stdin(Stdio::piped())
3998            .stdout(Stdio::piped())
3999            .kill_on_drop(true)
4000            .spawn()
4001            .unwrap();
4002        let write_stdin = write_half.stdin.take().unwrap();
4003        let write_stdout = write_half.stdout.take().unwrap();
4004
4005        let mut read_half = Command::new("cat")
4006            .stdin(Stdio::piped())
4007            .stdout(Stdio::piped())
4008            .kill_on_drop(true)
4009            .spawn()
4010            .unwrap();
4011        let read_stdout = read_half.stdout.take().unwrap();
4012        let read_stdin = read_half.stdin.take().unwrap();
4013
4014        let transport = LspTransport::new(write_stdin, read_stdout);
4015        let client = LspClient::from_transport(LspServerConfig::rust_analyzer(), transport);
4016
4017        (
4018            client,
4019            FakeServer {
4020                _write_half: write_half,
4021                _read_half: read_half,
4022                read_half_stdin: read_stdin,
4023                write_stdout,
4024            },
4025        )
4026    }
4027
4028    /// Reads one `Content-Length`-framed JSON-RPC message off `reader`.
4029    ///
4030    /// `reader` must be reused across calls, not recreated per message: a
4031    /// fresh `BufReader` would silently drop any bytes of a later message it
4032    /// over-read into its internal buffer while parsing an earlier one.
4033    async fn read_framed_message(reader: &mut BufReader<&mut ChildStdout>) -> JsonValue {
4034        let mut content_length = None;
4035        let mut line = String::new();
4036        loop {
4037            line.clear();
4038            reader.read_line(&mut line).await.unwrap();
4039            if line == "\r\n" || line == "\n" {
4040                break;
4041            }
4042            if let Some((key, value)) = line.trim_end().split_once(':')
4043                && key.trim().eq_ignore_ascii_case("content-length")
4044            {
4045                content_length = Some(value.trim().parse::<usize>().unwrap());
4046            }
4047        }
4048        let mut buf = vec![0u8; content_length.unwrap()];
4049        reader.read_exact(&mut buf).await.unwrap();
4050        serde_json::from_slice(&buf).unwrap()
4051    }
4052
4053    /// Writes a framed JSON-RPC success response, as a real LSP server would.
4054    async fn write_response(stdin: &mut ChildStdin, id: &JsonValue, result: JsonValue) {
4055        let message = serde_json::json!({
4056            "jsonrpc": "2.0",
4057            "id": id,
4058            "result": result,
4059        });
4060        let content = serde_json::to_string(&message).unwrap();
4061        let header = format!("Content-Length: {}\r\n\r\n", content.len());
4062        stdin.write_all(header.as_bytes()).await.unwrap();
4063        stdin.write_all(content.as_bytes()).await.unwrap();
4064        stdin.flush().await.unwrap();
4065    }
4066
4067    /// Writes a framed JSON-RPC error response, e.g. to simulate a push-only
4068    /// server answering `textDocument/diagnostic` with method-not-found.
4069    async fn write_error_response(
4070        stdin: &mut ChildStdin,
4071        id: &JsonValue,
4072        code: i64,
4073        message: &str,
4074    ) {
4075        let response = serde_json::json!({
4076            "jsonrpc": "2.0",
4077            "id": id,
4078            "error": {
4079                "code": code,
4080                "message": message,
4081            },
4082        });
4083        let content = serde_json::to_string(&response).unwrap();
4084        let header = format!("Content-Length: {}\r\n\r\n", content.len());
4085        stdin.write_all(header.as_bytes()).await.unwrap();
4086        stdin.write_all(content.as_bytes()).await.unwrap();
4087        stdin.flush().await.unwrap();
4088    }
4089
4090    #[tokio::test]
4091    async fn test_concurrent_handlers_on_different_files_do_not_serialize() {
4092        // Before the fix, Translator was shared as Arc<Mutex<Translator>>, so
4093        // handling one LSP request held that lock across the `.await` on the
4094        // response -- blocking every other tool call, even for a completely
4095        // different file and language server, until the first request
4096        // completed or timed out (up to 30s). With interior mutability, a
4097        // concurrent call for a different file must complete without waiting
4098        // on an unrelated in-flight request.
4099        let dir = TempDir::new().unwrap();
4100        let mut extensions = HashMap::new();
4101        extensions.insert("aa".to_string(), "lang_a".to_string());
4102        extensions.insert("bb".to_string(), "lang_b".to_string());
4103
4104        let mut translator =
4105            Translator::new()
4106                .with_extensions(extensions)
4107                .with_router(ToolRouter::catch_all([
4108                    (ServerId::from("lang_a"), "lang_a".to_string()),
4109                    (ServerId::from("lang_b"), "lang_b".to_string()),
4110                ]));
4111        translator.set_workspace_roots(vec![dir.path().to_path_buf()]);
4112
4113        let (client_a, mut server_a) = fake_lsp_client();
4114        let (client_b, mut server_b) = fake_lsp_client();
4115        translator.register_client("lang_a".to_string(), client_a);
4116        translator.register_client("lang_b".to_string(), client_b);
4117
4118        let path_a = dir.path().join("file.aa");
4119        let path_b = dir.path().join("file.bb");
4120        fs::write(&path_a, "content a").unwrap();
4121        fs::write(&path_b, "content b").unwrap();
4122
4123        let translator = Arc::new(translator);
4124
4125        // `server_a` is never given a response, simulating a slow server. If
4126        // any translator-held lock still spanned the LSP round trip, this
4127        // task blocking forever would also block the "fast" call below.
4128        let slow = {
4129            let translator = Arc::clone(&translator);
4130            let path = path_a.to_string_lossy().to_string();
4131            tokio::spawn(async move { translator.handle_hover(path, 1, 1).await })
4132        };
4133
4134        // Wait for the slow task to actually reach its LSP request (i.e. the
4135        // request bytes were written to the wire) before treating it as
4136        // "in-flight", so the test doesn't race the spawned task's startup.
4137        let mut wire_a = BufReader::new(&mut server_a.write_stdout);
4138        let opened_a = read_framed_message(&mut wire_a).await;
4139        assert_eq!(opened_a["method"], "textDocument/didOpen");
4140        let hover_request_a = read_framed_message(&mut wire_a).await;
4141        assert_eq!(hover_request_a["method"], "textDocument/hover");
4142
4143        // The fast path: a concurrent call for a different file/server.
4144        let fast = {
4145            let translator = Arc::clone(&translator);
4146            let path = path_b.to_string_lossy().to_string();
4147            tokio::spawn(async move { translator.handle_hover(path, 1, 1).await })
4148        };
4149
4150        let mut wire_b = BufReader::new(&mut server_b.write_stdout);
4151        let opened_b = read_framed_message(&mut wire_b).await;
4152        assert_eq!(opened_b["method"], "textDocument/didOpen");
4153        let hover_request_b = read_framed_message(&mut wire_b).await;
4154        assert_eq!(hover_request_b["method"], "textDocument/hover");
4155        write_response(
4156            &mut server_b.read_half_stdin,
4157            &hover_request_b["id"],
4158            JsonValue::Null,
4159        )
4160        .await;
4161
4162        let fast_result = timeout(Duration::from_secs(2), fast)
4163            .await
4164            .expect("fast call must not be blocked by the slow in-flight request")
4165            .unwrap();
4166        assert!(fast_result.is_ok());
4167
4168        assert!(
4169            !slow.is_finished(),
4170            "slow call should still be waiting on its (never-sent) response"
4171        );
4172        slow.abort();
4173    }
4174
4175    #[tokio::test]
4176    async fn test_concurrent_ensure_open_same_path_sends_single_did_open() {
4177        // Regression test: concurrent handler calls for the SAME path must
4178        // serialize on that path's `ensure_open` lock (see `DocumentTracker::lock_path`)
4179        // so they can't both observe "not open yet" and both send didOpen.
4180        let dir = TempDir::new().unwrap();
4181        let mut extensions = HashMap::new();
4182        extensions.insert("aa".to_string(), "lang_a".to_string());
4183
4184        let mut translator =
4185            Translator::new()
4186                .with_extensions(extensions)
4187                .with_router(ToolRouter::catch_all([(
4188                    ServerId::from("lang_a"),
4189                    "lang_a".to_string(),
4190                )]));
4191        translator.set_workspace_roots(vec![dir.path().to_path_buf()]);
4192
4193        let (client, mut server) = fake_lsp_client();
4194        translator.register_client("lang_a".to_string(), client);
4195
4196        let path = dir.path().join("file.aa");
4197        fs::write(&path, "content").unwrap();
4198
4199        let concurrent_calls = 4;
4200
4201        let translator = Arc::new(translator);
4202        let path_str = path.to_string_lossy().to_string();
4203
4204        let handles: Vec<_> = (0..concurrent_calls)
4205            .map(|_| {
4206                let translator = Arc::clone(&translator);
4207                let path_str = path_str.clone();
4208                tokio::spawn(async move { translator.handle_hover(path_str, 1, 1).await })
4209            })
4210            .collect();
4211
4212        let mut wire = BufReader::new(&mut server.write_stdout);
4213        let opened = read_framed_message(&mut wire).await;
4214        assert_eq!(opened["method"], "textDocument/didOpen");
4215
4216        for _ in 0..concurrent_calls {
4217            let request = read_framed_message(&mut wire).await;
4218            assert_eq!(
4219                request["method"], "textDocument/hover",
4220                "no second didOpen must appear ahead of the hover requests"
4221            );
4222            write_response(&mut server.read_half_stdin, &request["id"], JsonValue::Null).await;
4223        }
4224
4225        for handle in handles {
4226            let result = timeout(Duration::from_secs(2), handle)
4227                .await
4228                .expect("handler call should not hang")
4229                .unwrap();
4230            assert!(result.is_ok());
4231        }
4232    }
4233
4234    /// #174 §12's own headline dispatch scenario: "pyright/pylsp fixture --
4235    /// hover -> pyright, diagnostics -> pylsp, rename (unclaimed) ->
4236    /// `NoServerForTool`", exercised through `Translator`'s public handlers
4237    /// end to end rather than through `ToolRouter`'s unit tests alone.
4238    #[tokio::test]
4239    async fn test_dispatch_routes_hover_and_diagnostics_to_different_servers() {
4240        let dir = TempDir::new().unwrap();
4241        let mut extensions = HashMap::new();
4242        extensions.insert("py".to_string(), "python".to_string());
4243
4244        let pyright_id = ServerId::from("pyright");
4245        let pylsp_id = ServerId::from("pylsp");
4246        let configs = vec![
4247            LspServerConfig {
4248                language_id: "python".to_string(),
4249                command: "pyright-langserver".to_string(),
4250                args: vec![],
4251                env: HashMap::new(),
4252                file_patterns: vec![],
4253                initialization_options: None,
4254                timeout_seconds: 30,
4255                heuristics: None,
4256                name: Some("pyright".to_string()),
4257                handles: Some(vec![ToolKind::Hover]),
4258            },
4259            LspServerConfig {
4260                language_id: "python".to_string(),
4261                command: "pylsp".to_string(),
4262                args: vec![],
4263                env: HashMap::new(),
4264                file_patterns: vec![],
4265                initialization_options: None,
4266                timeout_seconds: 30,
4267                heuristics: None,
4268                name: Some("pylsp".to_string()),
4269                handles: Some(vec![ToolKind::Diagnostics]),
4270            },
4271        ];
4272        let router = ToolRouter::from_configs(&configs).unwrap();
4273
4274        let mut translator = Translator::new()
4275            .with_extensions(extensions)
4276            .with_router(router);
4277        translator.set_workspace_roots(vec![dir.path().to_path_buf()]);
4278
4279        let (client_pyright, mut server_pyright) = fake_lsp_client();
4280        let (client_pylsp, mut server_pylsp) = fake_lsp_client();
4281        translator.register_client(pyright_id, client_pyright);
4282        translator.register_client(pylsp_id, client_pylsp);
4283
4284        let path = dir.path().join("main.py");
4285        fs::write(&path, "x = 1").unwrap();
4286        let path_str = path.to_string_lossy().to_string();
4287
4288        let translator = Arc::new(translator);
4289
4290        // rename is claimed by neither server -> NoServerForTool, checked
4291        // first so it can't be masked by either server's wire state.
4292        let rename_result = translator
4293            .handle_rename(path_str.clone(), 1, 1, "renamed".to_string())
4294            .await;
4295        assert!(
4296            matches!(
4297                rename_result,
4298                Err(Error::NoServerForTool {
4299                    tool: ToolKind::Rename,
4300                    ..
4301                })
4302            ),
4303            "expected NoServerForTool for rename, got {rename_result:?}"
4304        );
4305
4306        // hover must route to pyright: didOpen + hover request on its wire.
4307        let hover = {
4308            let translator = Arc::clone(&translator);
4309            let path_str = path_str.clone();
4310            tokio::spawn(async move { translator.handle_hover(path_str, 1, 1).await })
4311        };
4312        let mut wire_pyright = BufReader::new(&mut server_pyright.write_stdout);
4313        let opened = read_framed_message(&mut wire_pyright).await;
4314        assert_eq!(opened["method"], "textDocument/didOpen");
4315        let hover_request = read_framed_message(&mut wire_pyright).await;
4316        assert_eq!(hover_request["method"], "textDocument/hover");
4317        write_response(
4318            &mut server_pyright.read_half_stdin,
4319            &hover_request["id"],
4320            JsonValue::Null,
4321        )
4322        .await;
4323        hover
4324            .await
4325            .unwrap()
4326            .expect("hover routed to pyright must succeed");
4327
4328        // diagnostics must route to pylsp, independently of pyright: its own
4329        // didOpen (a second server's first sync of the same path) followed
4330        // by the diagnostic request on pylsp's wire, never pyright's.
4331        let diagnostics = {
4332            let translator = Arc::clone(&translator);
4333            let notification_cache = Arc::new(Mutex::new(NotificationCache::new()));
4334            tokio::spawn(async move {
4335                translator
4336                    .handle_diagnostics(path_str, &notification_cache)
4337                    .await
4338            })
4339        };
4340        let mut wire_pylsp = BufReader::new(&mut server_pylsp.write_stdout);
4341        let opened = read_framed_message(&mut wire_pylsp).await;
4342        assert_eq!(opened["method"], "textDocument/didOpen");
4343        let diag_request = read_framed_message(&mut wire_pylsp).await;
4344        assert_eq!(diag_request["method"], "textDocument/diagnostic");
4345        // Routing is proven by the request landing on pylsp's wire; abort
4346        // rather than crafting a well-formed DocumentDiagnosticReportResult.
4347        diagnostics.abort();
4348    }
4349
4350    /// S1 regression (#244): a push-only server (or one that times out)
4351    /// answering `textDocument/diagnostic` with an LSP error must not
4352    /// discard diagnostics `handle_diagnostics` already knows about from the
4353    /// cache -- it should return the cache-only result instead of `Err`.
4354    #[tokio::test]
4355    async fn test_handle_diagnostics_pull_error_falls_back_to_nonempty_cache() {
4356        let dir = TempDir::new().unwrap();
4357        let mut extensions = HashMap::new();
4358        extensions.insert("rs".to_string(), "rust".to_string());
4359
4360        let mut translator =
4361            Translator::new()
4362                .with_extensions(extensions)
4363                .with_router(ToolRouter::catch_all([(
4364                    ServerId::from("rust"),
4365                    "rust".to_string(),
4366                )]));
4367        translator.set_workspace_roots(vec![dir.path().to_path_buf()]);
4368
4369        let (client, mut server) = fake_lsp_client();
4370        translator.register_client("rust".to_string(), client);
4371
4372        let path = dir.path().join("lib.rs");
4373        fs::write(&path, "fn main() {}").unwrap();
4374        let path_str = path.to_string_lossy().to_string();
4375
4376        // Prime the cache under the exact URI handle_diagnostics will look
4377        // up (path_to_uri over the canonicalized path, same as
4378        // document_tracker uses to open the document).
4379        let canonical = path.canonicalize().unwrap();
4380        let uri = path_to_uri(&canonical);
4381        let notification_cache = Mutex::new(NotificationCache::new());
4382        {
4383            let mut cache = notification_cache.lock().await;
4384            cache.store_diagnostics(
4385                &uri,
4386                Some(1),
4387                vec![lsp_diag(
4388                    0,
4389                    4,
4390                    lsp_types::DiagnosticSeverity::WARNING,
4391                    "unused import: `std::fmt`",
4392                    None,
4393                )],
4394            );
4395        }
4396
4397        let translator = Arc::new(translator);
4398        let handle = {
4399            let translator = Arc::clone(&translator);
4400            tokio::spawn(async move {
4401                translator
4402                    .handle_diagnostics(path_str, &notification_cache)
4403                    .await
4404            })
4405        };
4406
4407        let mut wire = BufReader::new(&mut server.write_stdout);
4408        let opened = read_framed_message(&mut wire).await;
4409        assert_eq!(opened["method"], "textDocument/didOpen");
4410        let diag_request = read_framed_message(&mut wire).await;
4411        assert_eq!(diag_request["method"], "textDocument/diagnostic");
4412        write_error_response(
4413            &mut server.read_half_stdin,
4414            &diag_request["id"],
4415            -32601,
4416            "method not found",
4417        )
4418        .await;
4419
4420        let result = timeout(Duration::from_secs(2), handle)
4421            .await
4422            .expect("handler call should not hang")
4423            .unwrap();
4424
4425        let diagnostics = result.expect("cache-only fallback should succeed despite pull error");
4426        assert_eq!(diagnostics.diagnostics.len(), 1);
4427        assert_eq!(
4428            diagnostics.diagnostics[0].message,
4429            "unused import: `std::fmt`"
4430        );
4431    }
4432
4433    /// S1 counterpart: when the cache is also empty, the pull error must
4434    /// still propagate -- there is nothing to fall back to.
4435    #[tokio::test]
4436    async fn test_handle_diagnostics_pull_error_and_empty_cache_propagates_error() {
4437        let dir = TempDir::new().unwrap();
4438        let mut extensions = HashMap::new();
4439        extensions.insert("rs".to_string(), "rust".to_string());
4440
4441        let mut translator =
4442            Translator::new()
4443                .with_extensions(extensions)
4444                .with_router(ToolRouter::catch_all([(
4445                    ServerId::from("rust"),
4446                    "rust".to_string(),
4447                )]));
4448        translator.set_workspace_roots(vec![dir.path().to_path_buf()]);
4449
4450        let (client, mut server) = fake_lsp_client();
4451        translator.register_client("rust".to_string(), client);
4452
4453        let path = dir.path().join("lib.rs");
4454        fs::write(&path, "fn main() {}").unwrap();
4455        let path_str = path.to_string_lossy().to_string();
4456
4457        let notification_cache = Mutex::new(NotificationCache::new());
4458
4459        let translator = Arc::new(translator);
4460        let handle = {
4461            let translator = Arc::clone(&translator);
4462            tokio::spawn(async move {
4463                translator
4464                    .handle_diagnostics(path_str, &notification_cache)
4465                    .await
4466            })
4467        };
4468
4469        let mut wire = BufReader::new(&mut server.write_stdout);
4470        let opened = read_framed_message(&mut wire).await;
4471        assert_eq!(opened["method"], "textDocument/didOpen");
4472        let diag_request = read_framed_message(&mut wire).await;
4473        assert_eq!(diag_request["method"], "textDocument/diagnostic");
4474        write_error_response(
4475            &mut server.read_half_stdin,
4476            &diag_request["id"],
4477            -32601,
4478            "method not found",
4479        )
4480        .await;
4481
4482        let result = timeout(Duration::from_secs(2), handle)
4483            .await
4484            .expect("handler call should not hang")
4485            .unwrap();
4486
4487        assert!(
4488            result.is_err(),
4489            "pull error with no cache data must propagate, got {result:?}"
4490        );
4491    }
4492}