Skip to main content

mcpls_core/bridge/translator/
routing.rs

1//! Client/server routing, document-open preparation, and capability gating
2//! shared by every LSP-round-trip tool-call handler.
3
4use std::path::{Path, PathBuf};
5use std::sync::Arc;
6
7use super::Translator;
8use super::positioned::DisclosedDocument;
9use crate::bridge::resources::{DiagnosticsResourceUri, parse_uri};
10use crate::bridge::{
11    Access, ClientPath, InFlightGuard, LinePresence, OpenRequest, Position, WorkspacePath,
12};
13use crate::config::{
14    FileKey, LanguageId, NoServerReason, ServerId, ToolKind, ToolRouter, base_language_id,
15};
16use crate::error::{Error, ErrorChain, Result, ServerSpawnFailure};
17use crate::lsp::LspClient;
18use crate::util::{MAX_ECHOED_PATH_BYTES, escape_bounded, lock_std};
19
20/// Total time `Translator::flush_pending_closes` may spend per call.
21const FLUSH_PENDING_CLOSES_DEADLINE: std::time::Duration = std::time::Duration::from_secs(1);
22
23/// A document opened for a handler's LSP round-trip, together with the
24/// routed server and client.
25///
26/// Owns an [`InFlightGuard`], so the document cannot be evicted by the
27/// document tracker's LRU while this value is alive (#503). Fields are
28/// reachable only through borrowing accessors, so a handler must keep the
29/// whole `PreparedDocument` bound until its request completes -- destructuring
30/// it would drop the guard early.
31#[derive(Debug)]
32#[must_use = "dropping a PreparedDocument makes its document evictable mid-request"]
33pub(super) struct PreparedDocument {
34    server_id: ServerId,
35    client: LspClient,
36    uri: lsp_types::Uri,
37    path: PathBuf,
38    _in_flight: InFlightGuard,
39}
40
41impl PreparedDocument {
42    pub(super) const fn server_id(&self) -> &ServerId {
43        &self.server_id
44    }
45
46    pub(super) const fn client(&self) -> &LspClient {
47        &self.client
48    }
49
50    pub(super) const fn uri(&self) -> &lsp_types::Uri {
51        &self.uri
52    }
53
54    pub(super) fn path(&self) -> &Path {
55        &self.path
56    }
57}
58
59/// Whether opening a document waits for the routed server to finish indexing.
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61enum IndexingWait {
62    Wait,
63    Skip,
64}
65
66/// The capability a [`Translator::prepare_gated_document`] call site gates
67/// on, together with whether it also applies
68/// [`Translator::wait_for_indexing_ready`], declared explicitly at the same
69/// place capability-gating is declared so a newly added (or newly gated) tool
70/// can't silently ship without an indexing-readiness decision either way.
71///
72/// Only a [`FileLocalCapability`] fits [`Self::FileLocal`], so a
73/// name-resolving tool cannot skip the indexing wait by accident.
74///
75/// This only covers call sites that actually go through
76/// `prepare_gated_document`. The disclosed tools open through
77/// `prepare_disclosed_document`, which takes a [`DisclosedCapability`] and
78/// answers with an `Indexed` result, and `require_routed_capability` only
79/// checks a capability without opening anything. Two further production
80/// handlers bypass the chokepoint entirely and so make no `IndexingGate`
81/// decision at all:
82/// - `handle_workspace_symbol` (`workspace_symbol_search`) has no per-file
83///   document to resolve or open (it resolves via `resolve_any` instead), so
84///   it cannot be routed through this chokepoint as-is. Whether/how to gate
85///   it on indexing readiness was deferred as a separate open question (spec
86///   FR-008) and remains a known, deliberate limitation -- see #423.
87/// - `handle_diagnostics` calls the ungated `Translator::prepare_document`
88///   sibling directly, so it gets neither an indexing-readiness decision nor
89///   a capability check. The indexing-readiness half of that is deliberate,
90///   not an oversight (#445): unlike `handle_incoming_calls`/`handle_outgoing_calls`
91///   (#423), it reads from the notification-cache poll path rather than
92///   issuing a live whole-workspace LSP request, so blocking it on
93///   `wait_for_indexing_ready` the way `Required` does for the other
94///   handlers would be the wrong fix shape (it would stall a cache read on a
95///   signal the cache itself doesn't need). Instead, the `get_diagnostics`
96///   MCP tool (`mcp::server::get_diagnostics`) independently resolves the
97///   file's diagnostics-route server and reports its indexing state as an
98///   explicit `indexing_in_progress` flag on the response
99///   (`mcp::server::DiagnosticsResponse`), so a mid-index pull (which can
100///   read as "no errors" while rust-analyzer is still loading) is flagged
101///   rather than silently trusted. The missing capability check was not
102///   analyzed as part of #445 and remains an open question.
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104pub(super) enum IndexingGate {
105    /// This tool's answer depends on whole-workspace analysis (e.g. hover,
106    /// definition, references, rename, completions, code actions, call
107    /// hierarchy incoming/outgoing calls): wait for indexing before opening.
108    Required(Capability),
109    /// This tool's answer is file-local, so it is valid mid-index and carries
110    /// no signal: document symbols (also used by name addressing and
111    /// `enclosing_symbol`), folding ranges, selection ranges, document
112    /// highlights, format document and format range.
113    ///
114    /// Name-resolving tools that must not stall a cold start (signature help,
115    /// inlay hints, prepare call/type hierarchy; the #423 scope decision) do
116    /// not fit here: they open through `Translator::prepare_disclosed_document`, whose
117    /// `DisclosedDocument` can only answer with an `Indexed` result carrying
118    /// `indexing_in_progress` (#668).
119    FileLocal(FileLocalCapability),
120}
121
122impl IndexingGate {
123    /// The capability the call site gates on.
124    pub(super) fn capability(self) -> Capability {
125        match self {
126            Self::Required(capability) => capability,
127            Self::FileLocal(capability) => capability.into(),
128        }
129    }
130}
131
132/// The capabilities whose answer is file-local and so valid mid-index; the
133/// only ones [`IndexingGate::FileLocal`] accepts.
134#[derive(Debug, Clone, Copy, PartialEq, Eq)]
135pub(super) enum FileLocalCapability {
136    /// [`Capability::DocumentSymbols`].
137    DocumentSymbols,
138    /// [`Capability::FoldingRange`].
139    FoldingRange,
140    /// [`Capability::SelectionRange`].
141    SelectionRange,
142    /// [`Capability::DocumentHighlights`].
143    DocumentHighlights,
144    /// [`Capability::FormatDocument`].
145    FormatDocument,
146    /// [`Capability::FormatRange`].
147    FormatRange,
148}
149
150impl From<FileLocalCapability> for Capability {
151    fn from(capability: FileLocalCapability) -> Self {
152        match capability {
153            FileLocalCapability::DocumentSymbols => Self::DocumentSymbols,
154            FileLocalCapability::FoldingRange => Self::FoldingRange,
155            FileLocalCapability::SelectionRange => Self::SelectionRange,
156            FileLocalCapability::DocumentHighlights => Self::DocumentHighlights,
157            FileLocalCapability::FormatDocument => Self::FormatDocument,
158            FileLocalCapability::FormatRange => Self::FormatRange,
159        }
160    }
161}
162
163/// The capabilities of the name-resolving tools that must not stall a cold
164/// start: they open through [`Translator::prepare_disclosed_document`], which
165/// never waits for indexing, so only these four can reach it.
166#[derive(Debug, Clone, Copy, PartialEq, Eq)]
167pub(super) enum DisclosedCapability {
168    /// [`Capability::SignatureHelp`].
169    SignatureHelp,
170    /// [`Capability::InlayHints`].
171    InlayHints,
172    /// [`Capability::CallHierarchy`].
173    CallHierarchy,
174    /// [`Capability::TypeHierarchy`].
175    TypeHierarchy,
176}
177
178impl From<DisclosedCapability> for Capability {
179    fn from(capability: DisclosedCapability) -> Self {
180        match capability {
181            DisclosedCapability::SignatureHelp => Self::SignatureHelp,
182            DisclosedCapability::InlayHints => Self::InlayHints,
183            DisclosedCapability::CallHierarchy => Self::CallHierarchy,
184            DisclosedCapability::TypeHierarchy => Self::TypeHierarchy,
185        }
186    }
187}
188
189/// An LSP server capability mcpls gates a tool on before dispatching its request.
190///
191/// Ties the [`ServerCapabilities`](lsp_types::ServerCapabilities) field name
192/// (used only for the error message, through `Display`) to the predicate that
193/// actually checks it, so the two cannot drift apart the way two independent,
194/// hand-picked call-site values could.
195#[derive(Debug, Clone, Copy, PartialEq, Eq)]
196pub enum Capability {
197    /// `completionProvider` (`textDocument/completion`).
198    Completions,
199    /// `signatureHelpProvider` (`textDocument/signatureHelp`).
200    SignatureHelp,
201    /// `inlayHintProvider` (`textDocument/inlayHint`).
202    InlayHints,
203    /// `hoverProvider` (`textDocument/hover`).
204    Hover,
205    /// `definitionProvider` (`textDocument/definition`).
206    Definition,
207    /// `referencesProvider` (`textDocument/references`).
208    References,
209    /// `implementationProvider` (`textDocument/implementation`).
210    Implementation,
211    /// `typeDefinitionProvider` (`textDocument/typeDefinition`).
212    TypeDefinition,
213    /// `callHierarchyProvider` (`textDocument/prepareCallHierarchy`,
214    /// `callHierarchy/incomingCalls`, `callHierarchy/outgoingCalls`).
215    CallHierarchy,
216    /// `renameProvider` (`textDocument/rename`).
217    Rename,
218    /// `documentFormattingProvider` (`textDocument/formatting`).
219    FormatDocument,
220    /// `codeActionProvider` (`textDocument/codeAction`).
221    CodeActions,
222    /// `documentSymbolProvider` (`textDocument/documentSymbol`).
223    DocumentSymbols,
224    /// `workspaceSymbolProvider` (`workspace/symbol`).
225    WorkspaceSymbols,
226    /// `declarationProvider` (`textDocument/declaration`).
227    Declaration,
228    /// `typeHierarchyProvider` (`textDocument/prepareTypeHierarchy`,
229    /// `typeHierarchy/supertypes`, `typeHierarchy/subtypes`).
230    TypeHierarchy,
231    /// `renameProvider.prepareProvider` (`textDocument/prepareRename`). Gated
232    /// on the [`ToolKind::Rename`] route, so it is never the primary
233    /// capability of its tool.
234    PrepareRename,
235    /// `documentHighlightProvider` (`textDocument/documentHighlight`).
236    DocumentHighlights,
237    /// `documentRangeFormattingProvider` (`textDocument/rangeFormatting`).
238    FormatRange,
239    /// `selectionRangeProvider` (`textDocument/selectionRange`).
240    SelectionRange,
241    /// `foldingRangeProvider` (`textDocument/foldingRange`).
242    FoldingRange,
243}
244
245#[expect(
246    clippy::indexing_slicing,
247    reason = "compile-time check; the loop condition keeps `i` below `ALL.len()`"
248)]
249const _: () = {
250    assert!(Capability::ALL.len() == Capability::FoldingRange as usize + 1);
251    assert!(Capability::ALL.len() <= u32::BITS as usize);
252    let mut i = 0;
253    while i < Capability::ALL.len() {
254        assert!(Capability::ALL[i] as usize == i);
255        i += 1;
256    }
257};
258
259impl Capability {
260    /// Every capability, in discriminant order.
261    pub(crate) const ALL: [Self; 21] = [
262        Self::Completions,
263        Self::SignatureHelp,
264        Self::InlayHints,
265        Self::Hover,
266        Self::Definition,
267        Self::References,
268        Self::Implementation,
269        Self::TypeDefinition,
270        Self::CallHierarchy,
271        Self::Rename,
272        Self::FormatDocument,
273        Self::CodeActions,
274        Self::DocumentSymbols,
275        Self::WorkspaceSymbols,
276        Self::Declaration,
277        Self::TypeHierarchy,
278        Self::PrepareRename,
279        Self::DocumentHighlights,
280        Self::FormatRange,
281        Self::SelectionRange,
282        Self::FoldingRange,
283    ];
284
285    /// The [`ToolKind`] whose route this capability gates -- the single
286    /// source tying a gated handler's routing to its capability check.
287    pub(crate) const fn tool_kind(self) -> ToolKind {
288        match self {
289            Self::Completions => ToolKind::Completions,
290            Self::SignatureHelp => ToolKind::SignatureHelp,
291            Self::InlayHints => ToolKind::InlayHints,
292            Self::Hover => ToolKind::Hover,
293            Self::Definition => ToolKind::Definition,
294            Self::References => ToolKind::References,
295            Self::Implementation => ToolKind::Implementation,
296            Self::TypeDefinition => ToolKind::TypeDefinition,
297            Self::CallHierarchy => ToolKind::CallHierarchy,
298            Self::Rename | Self::PrepareRename => ToolKind::Rename,
299            Self::FormatDocument => ToolKind::FormatDocument,
300            Self::CodeActions => ToolKind::CodeActions,
301            Self::DocumentSymbols => ToolKind::DocumentSymbols,
302            Self::WorkspaceSymbols => ToolKind::WorkspaceSymbols,
303            Self::Declaration => ToolKind::Declaration,
304            Self::TypeHierarchy => ToolKind::TypeHierarchy,
305            Self::DocumentHighlights => ToolKind::DocumentHighlights,
306            Self::FormatRange => ToolKind::FormatRange,
307            Self::SelectionRange => ToolKind::SelectionRange,
308            Self::FoldingRange => ToolKind::FoldingRange,
309        }
310    }
311
312    /// Whether this is the capability [`Self::for_tool`] returns for its
313    /// [`ToolKind`]. Only [`Self::PrepareRename`] is not: it shares the
314    /// `Rename` route, and `Rename` precedes it in [`Self::ALL`].
315    pub(crate) const fn is_primary(self) -> bool {
316        !matches!(self, Self::PrepareRename)
317    }
318
319    /// The primary capability gating `tool` (the first match in
320    /// [`Self::ALL`]), or `None` for a tool dispatched without a capability
321    /// check (`Diagnostics`). Derived from [`Self::tool_kind`] over
322    /// [`Self::ALL`], so the pairing has a single source.
323    pub(crate) const fn for_tool(tool: ToolKind) -> Option<Self> {
324        Self::find_gating(&Self::ALL, tool)
325    }
326
327    const fn find_gating(candidates: &[Self], tool: ToolKind) -> Option<Self> {
328        match candidates {
329            // `PartialEq` is not const, so compare discriminants.
330            [first, rest @ ..] => {
331                if first.is_primary() && first.tool_kind() as u8 == tool as u8 {
332                    Some(*first)
333                } else {
334                    Self::find_gating(rest, tool)
335                }
336            }
337            [] => None,
338        }
339    }
340
341    /// Whether a request gated on this capability may be dispatched against
342    /// a server with `caps`. Unknown capabilities (`None`) are assumed
343    /// supported, mirroring [`Translator::require_capability`]'s fail-open
344    /// stance.
345    pub(crate) const fn is_available(self, caps: Option<&lsp_types::ServerCapabilities>) -> bool {
346        match caps {
347            Some(caps) => self.is_supported(caps),
348            None => true,
349        }
350    }
351
352    /// The `ServerCapabilities` field name, as reported to the MCP caller in
353    /// [`Error::CapabilityNotSupported`].
354    pub(crate) const fn name(self) -> &'static str {
355        match self {
356            Self::Completions => "completionProvider",
357            Self::SignatureHelp => "signatureHelpProvider",
358            Self::InlayHints => "inlayHintProvider",
359            Self::Hover => "hoverProvider",
360            Self::Definition => "definitionProvider",
361            Self::References => "referencesProvider",
362            Self::Implementation => "implementationProvider",
363            Self::TypeDefinition => "typeDefinitionProvider",
364            Self::CallHierarchy => "callHierarchyProvider",
365            Self::Rename => "renameProvider",
366            Self::FormatDocument => "documentFormattingProvider",
367            Self::CodeActions => "codeActionProvider",
368            Self::DocumentSymbols => "documentSymbolProvider",
369            Self::WorkspaceSymbols => "workspaceSymbolProvider",
370            Self::Declaration => "declarationProvider",
371            Self::TypeHierarchy => "typeHierarchyProvider",
372            Self::PrepareRename => "renameProvider.prepareProvider",
373            Self::DocumentHighlights => "documentHighlightProvider",
374            Self::FormatRange => "documentRangeFormattingProvider",
375            Self::SelectionRange => "selectionRangeProvider",
376            Self::FoldingRange => "foldingRangeProvider",
377        }
378    }
379
380    /// Whether `caps` advertises support for this capability.
381    #[allow(clippy::too_many_lines, reason = "one match arm per capability")]
382    pub(crate) const fn is_supported(self, caps: &lsp_types::ServerCapabilities) -> bool {
383        match self {
384            Self::Completions => caps.completion_provider.is_some(),
385            Self::SignatureHelp => caps.signature_help_provider.is_some(),
386            Self::InlayHints => matches!(
387                caps.inlay_hint_provider,
388                Some(
389                    lsp_types::InlayHintProvider::Bool(true)
390                        | lsp_types::InlayHintProvider::InlayHintOptions(_)
391                        | lsp_types::InlayHintProvider::InlayHintRegistrationOptions(_)
392                )
393            ),
394            Self::Hover => matches!(
395                caps.hover_provider,
396                Some(
397                    lsp_types::HoverProvider::Bool(true)
398                        | lsp_types::HoverProvider::HoverOptions(_)
399                )
400            ),
401            Self::Definition => matches!(
402                caps.definition_provider,
403                Some(
404                    lsp_types::DefinitionProvider::Bool(true)
405                        | lsp_types::DefinitionProvider::DefinitionOptions(_)
406                )
407            ),
408            Self::References => matches!(
409                caps.references_provider,
410                Some(
411                    lsp_types::ReferencesProvider::Bool(true)
412                        | lsp_types::ReferencesProvider::ReferenceOptions(_)
413                )
414            ),
415            Self::Implementation => matches!(
416                caps.implementation_provider,
417                Some(
418                    lsp_types::ImplementationProvider::Bool(true)
419                        | lsp_types::ImplementationProvider::ImplementationOptions(_)
420                        | lsp_types::ImplementationProvider::ImplementationRegistrationOptions(_)
421                )
422            ),
423            Self::TypeDefinition => matches!(
424                caps.type_definition_provider,
425                Some(
426                    lsp_types::TypeDefinitionProvider::Bool(true)
427                        | lsp_types::TypeDefinitionProvider::TypeDefinitionOptions(_)
428                        | lsp_types::TypeDefinitionProvider::TypeDefinitionRegistrationOptions(_)
429                )
430            ),
431            Self::CallHierarchy => matches!(
432                caps.call_hierarchy_provider,
433                Some(
434                    lsp_types::CallHierarchyProvider::Bool(true)
435                        | lsp_types::CallHierarchyProvider::CallHierarchyOptions(_)
436                        | lsp_types::CallHierarchyProvider::CallHierarchyRegistrationOptions(_)
437                )
438            ),
439            Self::Rename => matches!(
440                caps.rename_provider,
441                Some(
442                    lsp_types::RenameProvider::Bool(true)
443                        | lsp_types::RenameProvider::RenameOptions(_)
444                )
445            ),
446            Self::FormatDocument => matches!(
447                caps.document_formatting_provider,
448                Some(
449                    lsp_types::DocumentFormattingProvider::Bool(true)
450                        | lsp_types::DocumentFormattingProvider::DocumentFormattingOptions(_)
451                )
452            ),
453            Self::CodeActions => matches!(
454                caps.code_action_provider,
455                Some(
456                    lsp_types::CodeActionProvider::Bool(true)
457                        | lsp_types::CodeActionProvider::CodeActionOptions(_)
458                )
459            ),
460            Self::DocumentSymbols => matches!(
461                caps.document_symbol_provider,
462                Some(
463                    lsp_types::DocumentSymbolProvider::Bool(true)
464                        | lsp_types::DocumentSymbolProvider::DocumentSymbolOptions(_)
465                )
466            ),
467            Self::WorkspaceSymbols => matches!(
468                caps.workspace_symbol_provider,
469                Some(
470                    lsp_types::WorkspaceSymbolProvider::Bool(true)
471                        | lsp_types::WorkspaceSymbolProvider::WorkspaceSymbolOptions(_)
472                )
473            ),
474            Self::Declaration => matches!(
475                caps.declaration_provider,
476                Some(
477                    lsp_types::DeclarationProvider::Bool(true)
478                        | lsp_types::DeclarationProvider::DeclarationOptions(_)
479                        | lsp_types::DeclarationProvider::DeclarationRegistrationOptions(_)
480                )
481            ),
482            Self::TypeHierarchy => matches!(
483                caps.type_hierarchy_provider,
484                Some(
485                    lsp_types::TypeHierarchyProvider::Bool(true)
486                        | lsp_types::TypeHierarchyProvider::TypeHierarchyOptions(_)
487                        | lsp_types::TypeHierarchyProvider::TypeHierarchyRegistrationOptions(_)
488                )
489            ),
490            Self::PrepareRename => matches!(
491                caps.rename_provider,
492                Some(lsp_types::RenameProvider::RenameOptions(
493                    lsp_types::RenameOptions {
494                        prepare_provider: Some(true),
495                        ..
496                    }
497                ))
498            ),
499            Self::DocumentHighlights => matches!(
500                caps.document_highlight_provider,
501                Some(
502                    lsp_types::DocumentHighlightProvider::Bool(true)
503                        | lsp_types::DocumentHighlightProvider::DocumentHighlightOptions(_)
504                )
505            ),
506            Self::FormatRange => matches!(
507                caps.document_range_formatting_provider,
508                Some(
509                    lsp_types::DocumentRangeFormattingProvider::Bool(true)
510                        | lsp_types::DocumentRangeFormattingProvider::DocumentRangeFormattingOptions(_)
511                )
512            ),
513            Self::SelectionRange => matches!(
514                caps.selection_range_provider,
515                Some(
516                    lsp_types::SelectionRangeProvider::Bool(true)
517                        | lsp_types::SelectionRangeProvider::SelectionRangeOptions(_)
518                        | lsp_types::SelectionRangeProvider::SelectionRangeRegistrationOptions(_)
519                )
520            ),
521            Self::FoldingRange => matches!(
522                caps.folding_range_provider,
523                Some(
524                    lsp_types::FoldingRangeProvider::Bool(true)
525                        | lsp_types::FoldingRangeProvider::FoldingRangeOptions(_)
526                        | lsp_types::FoldingRangeProvider::FoldingRangeRegistrationOptions(_)
527                )
528            ),
529        }
530    }
531}
532
533impl std::fmt::Display for Capability {
534    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
535        f.write_str(self.name())
536    }
537}
538
539impl serde::Serialize for Capability {
540    fn serialize<S: serde::Serializer>(
541        &self,
542        serializer: S,
543    ) -> std::result::Result<S::Ok, S::Error> {
544        serializer.serialize_str(self.name())
545    }
546}
547
548/// Serialized as the `ServerCapabilities` field name its `Display` prints.
549impl schemars::JsonSchema for Capability {
550    fn schema_name() -> std::borrow::Cow<'static, str> {
551        "Capability".into()
552    }
553
554    fn json_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
555        schemars::json_schema!({
556            "type": "string",
557            "description": "LSP server capability the tool is gated on."
558        })
559    }
560}
561
562/// A file's detected language plus its React base-language fallback
563/// (`typescriptreact` -> `typescript`), in resolution order.
564///
565/// Single source of the candidate order for enforcement and the
566/// `get_tool_support` snapshot, so an explicit `typescriptreact` server still
567/// wins over the `typescript` fallback in both.
568#[derive(Debug, Clone, PartialEq, Eq)]
569pub(super) struct LanguageCandidates {
570    language: LanguageId,
571    base: Option<LanguageId>,
572}
573
574impl LanguageCandidates {
575    pub(super) fn new(language: LanguageId) -> Self {
576        let base = base_language_id(&language);
577        Self { language, base }
578    }
579
580    pub(super) const fn language(&self) -> &LanguageId {
581        &self.language
582    }
583
584    pub(super) fn iter(&self) -> impl Iterator<Item = &LanguageId> {
585        std::iter::once(&self.language).chain(self.base.as_ref())
586    }
587}
588
589/// Outcome of resolving a per-document tool route against the registries,
590/// shared by enforcement ([`Translator::client_for_file`]) and the
591/// `get_tool_support` snapshot so the two cannot disagree.
592#[derive(Debug)]
593pub(super) enum RouteLookup<T> {
594    /// The routed server is registered; `T` is whatever the registry lookup returned.
595    Registered(ServerId, T),
596    /// The routed server is expected but has not registered yet.
597    Initializing(ServerId),
598    /// The routed server failed to start and no catch-all is left to wait
599    /// for. Only seen while a router read races a settlement, since a settled
600    /// router drops such a route.
601    Failed(Box<ServerSpawnFailure>),
602    /// The router names a server that is neither registered, expected nor
603    /// recorded as failed.
604    Dangling {
605        language: LanguageId,
606        server_id: ServerId,
607    },
608    /// No candidate language has a route for the tool.
609    Unrouted,
610}
611
612/// Resolve the first candidate language with a route, then classify that
613/// route's server. `resolve`, `registered`, `is_expected`,
614/// `pending_catch_all` and `startup_failure` are separate closures so a caller
615/// backed by independent locks never holds two at once.
616///
617/// A route naming a failed server whose language's catch-all is still
618/// initializing (FR-007: the route is not bound to it yet) is `Initializing`
619/// naming that catch-all, the retryable state, rather than a terminal failure.
620pub(super) fn lookup_route<T>(
621    candidates: &LanguageCandidates,
622    resolve: impl Fn(&str) -> Option<ServerId>,
623    registered: impl Fn(&ServerId) -> Option<T>,
624    is_expected: impl Fn(&ServerId) -> bool,
625    pending_catch_all: impl Fn(&str) -> Option<ServerId>,
626    startup_failure: impl Fn(&ServerId) -> Option<ServerSpawnFailure>,
627) -> RouteLookup<T> {
628    for language in candidates.iter() {
629        let Some(server_id) = resolve(language.as_str()) else {
630            continue;
631        };
632        return if let Some(found) = registered(&server_id) {
633            RouteLookup::Registered(server_id, found)
634        } else if is_expected(&server_id) {
635            RouteLookup::Initializing(server_id)
636        } else if let Some(catch_all) = pending_catch_all(language.as_str()) {
637            RouteLookup::Initializing(catch_all)
638        } else if let Some(failure) = startup_failure(&server_id) {
639            RouteLookup::Failed(Box::new(failure))
640        } else {
641            RouteLookup::Dangling {
642                language: language.clone(),
643                server_id,
644            }
645        };
646    }
647    RouteLookup::Unrouted
648}
649
650/// The catch-all of `language` if it is still expected to register, read from
651/// the same `servers` guard as the rest of the lookup.
652fn pending_catch_all(
653    router: &ToolRouter,
654    servers: &super::servers::Servers,
655    language: &str,
656) -> Option<ServerId> {
657    router
658        .catch_all_for(language)
659        .filter(|catch_all| servers.is_expected(catch_all))
660        .cloned()
661}
662
663/// Where a file's diagnostics-route server stands, as seen by the cache-only
664/// diagnostics readers.
665///
666/// Resolved by [`Translator::diagnostics_route_for_path`] with the same rules
667/// as [`Translator::client_for_file`], so reading cached diagnostics cannot
668/// disagree with the pull tool about whether a server exists.
669#[derive(Debug)]
670pub enum DiagnosticsRoute {
671    /// The routed server is registered.
672    Live(ServerId),
673    /// The routed server is expected but has not registered yet.
674    Initializing(ServerId),
675    /// The server that would have served the file failed to start.
676    FailedToStart(Box<ServerSpawnFailure>),
677    /// No server is configured for the file's language.
678    Unrouted,
679}
680
681impl DiagnosticsRoute {
682    /// The routed server's id while it is registered or still starting.
683    pub(crate) const fn server_id(&self) -> Option<&ServerId> {
684        match self {
685            Self::Live(id) | Self::Initializing(id) => Some(id),
686            Self::FailedToStart(_) | Self::Unrouted => None,
687        }
688    }
689
690    /// Whether the server failed to start.
691    pub(crate) const fn is_failed(&self) -> bool {
692        matches!(self, Self::FailedToStart(_))
693    }
694
695    /// The startup failure, if the server failed to start.
696    ///
697    /// For subscription paths, where a still-starting server is fine (updates
698    /// will follow) but a failed one never publishes anything.
699    pub(crate) fn into_startup_failure(self) -> Option<Box<ServerSpawnFailure>> {
700        match self {
701            Self::FailedToStart(failure) => Some(failure),
702            Self::Live(_) | Self::Initializing(_) | Self::Unrouted => None,
703        }
704    }
705
706    /// Gates a cache read on the route: the live server's id, or `None` when
707    /// no server is configured (an empty cache is then the honest answer).
708    ///
709    /// # Errors
710    ///
711    /// [`Error::ServerInitializing`] while the server is still starting,
712    /// [`Error::ServerFailedToStart`] once it failed to start.
713    pub(crate) fn into_read_result(self) -> Result<Option<ServerId>> {
714        match self {
715            Self::Live(id) => Ok(Some(id)),
716            Self::Unrouted => Ok(None),
717            Self::Initializing(server_id) => Err(Error::ServerInitializing { server_id }),
718            Self::FailedToStart(failure) => Err(Error::ServerFailedToStart(failure)),
719        }
720    }
721}
722
723/// Outcome of resolving a workspace-wide tool route; see [`RouteLookup`].
724#[derive(Debug, PartialEq, Eq)]
725pub(super) enum WorkspaceRouteLookup {
726    /// The resolved server is registered.
727    Registered(ServerId),
728    /// The resolved server is expected but has not registered yet.
729    Initializing(ServerId),
730    /// The router resolved a server that is neither registered nor expected.
731    Dangling(ServerId),
732    /// Nothing is registered yet, but servers are still expected.
733    AllInitializing,
734    /// Nothing is registered and nothing is expected.
735    NothingConfigured,
736    /// Servers are registered, but none claims the tool and none is a catch-all.
737    NoClaimant,
738}
739
740/// Resolve a workspace-wide tool route and classify the resolved server.
741///
742/// The closures are separate so a caller backed by independent locks never
743/// holds two at once, as with [`lookup_route`].
744pub(super) fn lookup_workspace_route(
745    resolve: impl FnOnce() -> std::result::Result<ServerId, NoServerReason>,
746    is_registered: impl Fn(&ServerId) -> bool,
747    is_expected: impl Fn(&ServerId) -> bool,
748    expected_is_empty: impl FnOnce() -> bool,
749) -> WorkspaceRouteLookup {
750    match resolve() {
751        Ok(id) if is_registered(&id) => WorkspaceRouteLookup::Registered(id),
752        Ok(id) if is_expected(&id) => WorkspaceRouteLookup::Initializing(id),
753        Ok(id) => WorkspaceRouteLookup::Dangling(id),
754        Err(NoServerReason::NothingRegistered) if expected_is_empty() => {
755            WorkspaceRouteLookup::NothingConfigured
756        }
757        Err(NoServerReason::NothingRegistered) => WorkspaceRouteLookup::AllInitializing,
758        Err(NoServerReason::NoClaimant) => WorkspaceRouteLookup::NoClaimant,
759    }
760}
761
762/// Fail with [`Error::CapabilityNotSupported`] if `caps` is known and does
763/// not advertise `capability`.
764pub(super) fn check_capability(
765    server_id: &ServerId,
766    caps: Option<&lsp_types::ServerCapabilities>,
767    capability: Capability,
768) -> Result<()> {
769    capability
770        .is_available(caps)
771        .ok_or_else(|| Error::CapabilityNotSupported {
772            server_id: server_id.clone(),
773            capability,
774        })
775}
776
777impl Translator {
778    /// Validate that a path is within allowed workspace boundaries.
779    ///
780    /// # Errors
781    ///
782    /// Returns `Error::NoWorkspaceRoots` if no workspace roots are
783    /// configured (fails closed), or `Error::PathOutsideWorkspace` if the
784    /// path is outside all configured workspace roots.
785    pub(crate) async fn validate_path(&self, path: &ClientPath) -> Result<WorkspacePath> {
786        self.workspace_roots.validate(path).await
787    }
788
789    /// Resolve the client and routing identity for `path`/`tool`, giving the
790    /// resolved server a chance to be respawned first if its process has
791    /// died.
792    ///
793    /// Thin async wrapper around [`Self::client_for_file`] (kept
794    /// synchronous so its existing unit tests don't need a runtime): this is
795    /// the entry point async handlers call instead, so a dead server is
796    /// transparently replaced before its stale client is handed back.
797    pub(super) async fn resolve_client_for_file(
798        &self,
799        path: &Path,
800        tool: ToolKind,
801    ) -> Result<(ServerId, LspClient)> {
802        let (id, client) = self.client_for_file(path, tool)?;
803        self.respawn_if_dead(&id).await?;
804        let client = lock_std(&self.servers).client(&id).unwrap_or(client);
805        Ok((id, client))
806    }
807
808    /// Resolve the server that should handle `tool` for the file at `path`,
809    /// returning both its routing identity and a cloned client.
810    ///
811    /// Tries the file's detected language first, then (if that has no route)
812    /// its React base language (`.tsx` falling back from `typescriptreact` to
813    /// `typescript`, and similarly for `.jsx`) -- in that order, so an
814    /// explicit `typescriptreact` server still wins over the `typescript`
815    /// fallback when both are configured.
816    ///
817    /// Clones the router, then reads every closure's answer from one `servers`
818    /// guard (registered, expected, pending catch-all, failure) — every guard is
819    /// dropped before this method returns.
820    pub(super) fn client_for_file(
821        &self,
822        path: &Path,
823        tool: ToolKind,
824    ) -> Result<(ServerId, LspClient)> {
825        let candidates = self.language_candidates(path);
826        let router = self.router_snapshot();
827        let lookup = {
828            let servers = lock_std(&self.servers);
829            lookup_route(
830                &candidates,
831                |lang| router.resolve(lang, tool).cloned(),
832                |id| servers.client(id),
833                |id| servers.is_expected(id),
834                |lang| pending_catch_all(&router, &servers, lang),
835                |id| servers.failure(id).cloned(),
836            )
837        };
838        match lookup {
839            RouteLookup::Registered(id, client) => Ok((id, client)),
840            // A route naming a server that is still initializing (e.g. a
841            // large Unity solution loading via OmniSharp) -- tell the caller
842            // to wait and retry rather than implying no server is configured.
843            RouteLookup::Initializing(server_id) => Err(Error::ServerInitializing { server_id }),
844            RouteLookup::Failed(failure) => Err(Error::ServerFailedToStart(failure)),
845            // Unreachable once every server has settled and the router was
846            // re-derived (`Translator::rebind_router_to_settled`) -- a route
847            // can only name a registered, pending or failed server. Logged rather than
848            // `debug_assert!`-panicked: this method is reachable by any
849            // library consumer calling `with_router` without registering
850            // matching clients, not just internal misuse.
851            RouteLookup::Dangling {
852                language,
853                server_id,
854            } => {
855                tracing::error!(
856                    "router route names server '{server_id}' for tool '{tool}' that is neither \
857                     registered nor expected"
858                );
859                Err(Error::NoServerForTool {
860                    language_id: language,
861                    tool,
862                })
863            }
864            RouteLookup::Unrouted => {
865                if let Some(failure) = self.startup_failure_for_candidates(&candidates, tool) {
866                    return Err(Error::ServerFailedToStart(Box::new(failure)));
867                }
868                let has_language = {
869                    let router = lock_std(&self.router);
870                    candidates
871                        .iter()
872                        .any(|lang| router.has_language(lang.as_str()))
873                };
874                let language = candidates.language().clone();
875                if has_language {
876                    Err(Error::NoServerForTool {
877                        language_id: language,
878                        tool,
879                    })
880                } else {
881                    Err(Error::NoServerForLanguage {
882                        language,
883                        file: FileKey::of(path),
884                        patterns: Arc::clone(&self.file_patterns),
885                    })
886                }
887            }
888        }
889    }
890
891    /// The detected language of `path` plus its React base-language fallback.
892    pub(super) fn language_candidates(&self, path: &Path) -> LanguageCandidates {
893        LanguageCandidates::new(self.language_map.detect(path))
894    }
895
896    /// The startup failure of the server the pre-rebind routing table would
897    /// have used for `tool` in the first of `languages` that has one.
898    ///
899    /// Only consulted once no live route exists: a surviving catch-all
900    /// still serves the request, so a failed explicit server never masks it.
901    fn startup_failure_for_candidates(
902        &self,
903        candidates: &LanguageCandidates,
904        tool: ToolKind,
905    ) -> Option<ServerSpawnFailure> {
906        candidates.iter().find_map(|lang| {
907            let id = self.configured_router.resolve(lang.as_str(), tool)?;
908            self.startup_failure(id)
909        })
910    }
911
912    /// Classify the diagnostics-route server for `path`'s detected language.
913    ///
914    /// Mirrors [`Self::client_for_file`]'s language-candidate order and
915    /// registered/expected/failed classification, so a cache-only caller
916    /// (`get_cached_diagnostics`, the diagnostics resource) can tell an empty
917    /// cache from a server that is still starting or never started.
918    ///
919    /// The id of a [`DiagnosticsRoute::Live`] route is stable across a
920    /// respawn (only the registered client behind it changes), unlike
921    /// `NotificationCache::diagnostics_owner`, which a respawn clears -- so it
922    /// is what the push-degraded flag (#359) is keyed on.
923    #[must_use]
924    pub(crate) fn diagnostics_route_for_path(&self, path: &Path) -> DiagnosticsRoute {
925        let candidates = self.language_candidates(path);
926        let router = self.router_snapshot();
927        let lookup = {
928            let servers = lock_std(&self.servers);
929            lookup_route(
930                &candidates,
931                |lang| router.resolve(lang, ToolKind::Diagnostics).cloned(),
932                |id| servers.client(id).map(|_| ()),
933                |id| servers.is_expected(id),
934                |lang| pending_catch_all(&router, &servers, lang),
935                |id| servers.failure(id).cloned(),
936            )
937        };
938        match lookup {
939            RouteLookup::Registered(id, ()) => DiagnosticsRoute::Live(id),
940            RouteLookup::Initializing(id) => DiagnosticsRoute::Initializing(id),
941            RouteLookup::Failed(failure) => DiagnosticsRoute::FailedToStart(failure),
942            RouteLookup::Dangling { server_id, .. } => {
943                tracing::error!(
944                    "router diagnostics route names server '{server_id}' that is neither \
945                     registered nor expected"
946                );
947                DiagnosticsRoute::Unrouted
948            }
949            RouteLookup::Unrouted => self
950                .startup_failure_for_candidates(&candidates, ToolKind::Diagnostics)
951                .map_or(DiagnosticsRoute::Unrouted, |failure| {
952                    DiagnosticsRoute::FailedToStart(Box::new(failure))
953                }),
954        }
955    }
956
957    /// As [`Self::diagnostics_route_for_path`], for a canonical diagnostics
958    /// resource URI; a URI that does not parse is [`DiagnosticsRoute::Unrouted`].
959    #[must_use]
960    pub(crate) fn diagnostics_route_for_uri(
961        &self,
962        uri: &DiagnosticsResourceUri,
963    ) -> DiagnosticsRoute {
964        parse_uri(uri.as_str()).map_or(DiagnosticsRoute::Unrouted, |path| {
965            self.diagnostics_route_for_path(path.as_path())
966        })
967    }
968
969    /// Validate `file_path`, then resolve its routed client via
970    /// [`Self::resolve_client_for_file`] (respawn-aware), without opening
971    /// the document.
972    ///
973    /// Split out from [`Self::prepare_document`] so [`Self::prepare_gated_document`]
974    /// can check the routed server's capabilities *before* `ensure_open` sends
975    /// `textDocument/didOpen` -- a server rejected by the gate should never
976    /// observe an open notification for a request it can't service.
977    async fn resolve_validated_client_for_file(
978        &self,
979        file_path: &ClientPath,
980        tool: ToolKind,
981    ) -> Result<(ServerId, LspClient, WorkspacePath)> {
982        let validated_path = self.validate_path(file_path).await?;
983        let (server_id, client) = self
984            .resolve_client_for_file(validated_path.as_path(), tool)
985            .await?;
986        Ok((server_id, client, validated_path))
987    }
988
989    /// As [`Self::resolve_validated_client_for_file`], but for a caller that
990    /// already has a `&Path` it validated itself (e.g. `parse_file_uri`'s
991    /// return value). `path` is trusted to already be validated -- this does
992    /// *not* re-`canonicalize`/re-check it against workspace roots, unlike
993    /// the `&str` overload above, which always validates an untrusted MCP
994    /// input from scratch.
995    async fn resolve_validated_client_for_path(
996        &self,
997        path: &WorkspacePath,
998        tool: ToolKind,
999    ) -> Result<(ServerId, LspClient, WorkspacePath)> {
1000        let (server_id, client) = self.resolve_client_for_file(path.as_path(), tool).await?;
1001        Ok((server_id, client, path.clone()))
1002    }
1003
1004    /// Resolve the LSP client and ensure the document is open.
1005    ///
1006    /// This is the "prepare" phase shared by every LSP-round-trip handler:
1007    /// it validates the path, selects the client via
1008    /// [`Self::resolve_validated_client_for_file`] (respawn-aware), and
1009    /// calls `ensure_open`, which locks the document tracker's state only
1010    /// for the given path. The returned client and URI are owned values, so
1011    /// the caller can issue the actual LSP request (the "execute" phase)
1012    /// without holding any lock across the network round trip.
1013    ///
1014    /// `ensure_open`'s own awaits (a `stat`, optionally a re-read of the
1015    /// file, and the `textDocument/didOpen`/`didChange` notify) run under a
1016    /// lock scoped to `validated_path` alone — see [`DocumentTracker::ensure_open`]
1017    /// — so a slow or wedged language server cannot stall `prepare_document`
1018    /// calls for unrelated files. (Per-tool routing, #228, means the same
1019    /// file can be routed to more than one server; a wedged server-A notify
1020    /// still holds this path's lock and can therefore delay a healthy
1021    /// server-B call for that *same* file.)
1022    #[cfg(test)]
1023    pub(super) async fn prepare_document(
1024        &self,
1025        file_path: &ClientPath,
1026        tool: ToolKind,
1027    ) -> Result<PreparedDocument> {
1028        let path = self.validate_path(file_path).await?;
1029        self.prepare_document_for_path(&path, tool).await
1030    }
1031
1032    /// As [`Self::prepare_document`], for a path that is already validated.
1033    pub(super) async fn prepare_document_for_path(
1034        &self,
1035        path: &WorkspacePath,
1036        tool: ToolKind,
1037    ) -> Result<PreparedDocument> {
1038        let (server_id, client, validated_path) =
1039            self.resolve_validated_client_for_path(path, tool).await?;
1040        self.open_prepared(server_id, client, &validated_path, Access::Use)
1041            .await
1042    }
1043
1044    /// Marks `validated_path` in flight, then opens it via `ensure_open`.
1045    ///
1046    /// The guard is taken before `ensure_open` so there is no window between
1047    /// the path lock releasing and the guard taking effect (#503); it is
1048    /// dropped with the error on failure.
1049    pub(super) async fn open_prepared(
1050        &self,
1051        server_id: ServerId,
1052        client: LspClient,
1053        validated_path: &WorkspacePath,
1054        access: Access,
1055    ) -> Result<PreparedDocument> {
1056        let in_flight = self
1057            .document_tracker
1058            .mark_in_flight(validated_path.as_path());
1059        // Drained unconditionally, before propagating `ensure_open`'s
1060        // result: even on its error path (e.g. a `didOpen`/`didChange`
1061        // notify failure), `DocumentTracker::open` may already have evicted
1062        // a *different*, unrelated document and queued its `didClose` --
1063        // returning early via `?` before this would lose that queued close,
1064        // leaving the tracker desynced from that server (#495 S5).
1065        let result = self
1066            .document_tracker
1067            .ensure_open(OpenRequest::new(
1068                validated_path,
1069                &self.workspace_roots,
1070                &server_id,
1071                &client,
1072                access,
1073            ))
1074            .await;
1075        self.flush_pending_closes().await;
1076        let uri = result?;
1077        Ok(PreparedDocument {
1078            server_id,
1079            client,
1080            uri,
1081            path: validated_path.as_path().to_path_buf(),
1082            _in_flight: in_flight,
1083        })
1084    }
1085
1086    /// Rejects a position whose line lies beyond the end of `doc`'s tracked
1087    /// content, before any LSP request is made.
1088    ///
1089    /// Runs after the gates in `prepare_gated_document`, so an indexing wait
1090    /// precedes the check. A document that is no longer tracked passes: its
1091    /// extent is unknown, and the server stays the authority.
1092    ///
1093    /// # Errors
1094    ///
1095    /// Returns [`Error::PositionBeyondDocument`] when the line is past the end.
1096    pub(super) fn require_line_in_document(
1097        &self,
1098        doc: &PreparedDocument,
1099        position: Position,
1100    ) -> Result<()> {
1101        let line = position.line();
1102        match self
1103            .document_tracker
1104            .line_presence(doc.path(), line.get().saturating_sub(1))
1105        {
1106            LinePresence::Present => Ok(()),
1107            LinePresence::Beyond { last_line } => {
1108                Err(Error::PositionBeyondDocument { line, last_line })
1109            }
1110            LinePresence::Untracked => {
1111                tracing::debug!(
1112                    line = line.get(),
1113                    "skipping the line check for an untracked document"
1114                );
1115                Ok(())
1116            }
1117        }
1118    }
1119
1120    /// Like [`Self::prepare_document`], but checks `capability` against the
1121    /// routed server's `ServerCapabilities` *before* opening the document --
1122    /// see [`Self::resolve_client_for_file`]'s doc comment for why the
1123    /// ordering matters. When `indexing_gate` is
1124    /// [`IndexingGate::Required`], also waits for
1125    /// [`Self::wait_for_indexing_ready`] before opening the document, so a
1126    /// server still indexing never receives (or answers from) an opened
1127    /// document it would otherwise be asked about.
1128    ///
1129    /// # Errors
1130    ///
1131    /// Returns [`Error::CapabilityNotSupported`] if the routed server's
1132    /// `ServerCapabilities` explicitly does not advertise `capability`, or
1133    /// [`Error::WorkspaceIndexing`] if `indexing_gate` is
1134    /// [`IndexingGate::Required`] and the server is still indexing.
1135    pub(super) async fn prepare_gated_document(
1136        &self,
1137        file_path: &ClientPath,
1138        indexing_gate: IndexingGate,
1139    ) -> Result<PreparedDocument> {
1140        let (server_id, client, validated_path) = self
1141            .resolve_validated_client_for_file(file_path, indexing_gate.capability().tool_kind())
1142            .await?;
1143        self.finish_prepare_gated_document(server_id, client, validated_path, indexing_gate)
1144            .await
1145    }
1146
1147    /// As [`Self::prepare_gated_document`], then rejects every one of
1148    /// `positions` whose line lies beyond the end of the opened document, so a
1149    /// tool taking a position or range treats an out-of-document line the same
1150    /// way whichever language server answers (#641).
1151    ///
1152    /// The line check runs after the capability and indexing gates, so a
1153    /// refusal by a gate comes first; characters are never rejected.
1154    ///
1155    /// # Errors
1156    ///
1157    /// The errors of [`Self::prepare_gated_document`], and
1158    /// [`Error::PositionBeyondDocument`] for the first position past the end.
1159    pub(super) async fn prepare_positioned_document(
1160        &self,
1161        file_path: &ClientPath,
1162        indexing_gate: IndexingGate,
1163        positions: &[Position],
1164    ) -> Result<PreparedDocument> {
1165        let doc = self
1166            .prepare_gated_document(file_path, indexing_gate)
1167            .await?;
1168        self.require_lines_in_document(&doc, positions)?;
1169        Ok(doc)
1170    }
1171
1172    /// As [`Self::prepare_gated_document`], but for a caller that already
1173    /// has a `&Path` it validated itself (e.g. `handle_incoming_calls`/`handle_outgoing_calls`,
1174    /// via `parse_file_uri`) -- see [`Self::resolve_validated_client_for_path`]'s
1175    /// doc for why this skips re-validation.
1176    pub(super) async fn prepare_gated_document_for_path(
1177        &self,
1178        path: &WorkspacePath,
1179        indexing_gate: IndexingGate,
1180    ) -> Result<PreparedDocument> {
1181        let (server_id, client, validated_path) = self
1182            .resolve_validated_client_for_path(path, indexing_gate.capability().tool_kind())
1183            .await?;
1184        self.finish_prepare_gated_document(server_id, client, validated_path, indexing_gate)
1185            .await
1186    }
1187
1188    /// Opens `file_path` for a name-resolving tool that discloses indexing
1189    /// instead of waiting for it, after the capability gate and the line check
1190    /// of every one of `positions`.
1191    ///
1192    /// Takes a [`DisclosedCapability`] rather than an [`IndexingGate`]: it never
1193    /// waits, so only the tools that disclose indexing can reach it.
1194    ///
1195    /// # Errors
1196    ///
1197    /// The errors of [`Self::prepare_positioned_document`], without the
1198    /// indexing wait.
1199    pub(super) async fn prepare_disclosed_document(
1200        &self,
1201        file_path: &ClientPath,
1202        capability: DisclosedCapability,
1203        positions: &[Position],
1204    ) -> Result<DisclosedDocument<'_>> {
1205        let capability = Capability::from(capability);
1206        let (server_id, client, validated_path) = self
1207            .resolve_validated_client_for_file(file_path, capability.tool_kind())
1208            .await?;
1209        let doc = self
1210            .open_checked(
1211                server_id,
1212                client,
1213                &validated_path,
1214                capability,
1215                IndexingWait::Skip,
1216            )
1217            .await?;
1218        self.require_lines_in_document(&doc, positions)?;
1219        Ok(DisclosedDocument::new(self, doc))
1220    }
1221
1222    /// Checks that the server routed for `file_path` advertises `capability`,
1223    /// without opening the document.
1224    ///
1225    /// # Errors
1226    ///
1227    /// Returns the routing errors of [`Self::prepare_gated_document`] and
1228    /// [`Error::CapabilityNotSupported`].
1229    pub(super) async fn require_routed_capability(
1230        &self,
1231        file_path: &ClientPath,
1232        capability: Capability,
1233    ) -> Result<()> {
1234        let (server_id, _client, _path) = self
1235            .resolve_validated_client_for_file(file_path, capability.tool_kind())
1236            .await?;
1237        self.require_capability(&server_id, capability)
1238    }
1239
1240    /// Shared tail of [`Self::prepare_gated_document`] and
1241    /// [`Self::prepare_gated_document_for_path`], once each has resolved and
1242    /// validated its own path: capability-gate, indexing-gate, then open.
1243    async fn finish_prepare_gated_document(
1244        &self,
1245        server_id: ServerId,
1246        client: LspClient,
1247        validated_path: WorkspacePath,
1248        indexing_gate: IndexingGate,
1249    ) -> Result<PreparedDocument> {
1250        let wait = match indexing_gate {
1251            IndexingGate::Required(_) => IndexingWait::Wait,
1252            IndexingGate::FileLocal(_) => IndexingWait::Skip,
1253        };
1254        self.open_checked(
1255            server_id,
1256            client,
1257            &validated_path,
1258            indexing_gate.capability(),
1259            wait,
1260        )
1261        .await
1262    }
1263
1264    /// Capability-gates, optionally waits for indexing, then opens: the tail
1265    /// every document preparation shares.
1266    async fn open_checked(
1267        &self,
1268        server_id: ServerId,
1269        client: LspClient,
1270        validated_path: &WorkspacePath,
1271        capability: Capability,
1272        wait: IndexingWait,
1273    ) -> Result<PreparedDocument> {
1274        self.require_capability(&server_id, capability)?;
1275        if wait == IndexingWait::Wait {
1276            self.wait_for_indexing_ready(&server_id).await?;
1277        }
1278        self.open_prepared(server_id, client, validated_path, Access::Use)
1279            .await
1280    }
1281
1282    fn require_lines_in_document(
1283        &self,
1284        doc: &PreparedDocument,
1285        positions: &[Position],
1286    ) -> Result<()> {
1287        positions
1288            .iter()
1289            .try_for_each(|position| self.require_line_in_document(doc, *position))
1290    }
1291
1292    /// Sends the `textDocument/didClose` notifications still owed after
1293    /// [`DocumentTracker::open`]'s LRU eviction (#495), so a server's own
1294    /// open-document set does not keep growing even though mcpls's own
1295    /// tracking has stopped counting the document.
1296    ///
1297    /// `DocumentTracker` has no access to any server's [`LspClient`] --
1298    /// `self.servers` is the registry for that, kept one layer up in
1299    /// `Translator` -- so this is the chokepoint that reconciles its pending
1300    /// closes against it. Called after every `ensure_open` that could have
1301    /// triggered eviction, unconditionally, even when `ensure_open` itself
1302    /// returned an error, so a different, already-evicted document's close
1303    /// is never lost on that path (#495 S5).
1304    ///
1305    /// Claims, notifies and releases one path at a time, so a busy path is
1306    /// skipped (it stays pending) and no claim outlives its own notifies.
1307    /// The whole flush shares one [`FLUSH_PENDING_CLOSES_DEADLINE`]: it runs
1308    /// inline on the request path, so a wedged server must not add latency to
1309    /// unrelated calls. Paths not reached before the deadline stay pending.
1310    ///
1311    /// Best-effort: a failed close is logged and its debt dropped, never
1312    /// failing the request that triggered the eviction. A close whose send was
1313    /// cut short (the call was cancelled, or the deadline passed) stays owed.
1314    /// A wedged or dead server is respawned, and `forget_server` clears its
1315    /// history anyway. A server that is no longer registered is dropped the
1316    /// same way.
1317    async fn flush_pending_closes(&self) {
1318        let flush = async {
1319            for path in self.document_tracker.pending_close_paths() {
1320                let Some(mut claim) = self.document_tracker.try_claim_pending_close(&path) else {
1321                    continue;
1322                };
1323                let owed: Vec<ServerId> = claim.servers().cloned().collect();
1324                for server_id in owed {
1325                    let Some(client) = lock_std(&self.servers).client(&server_id) else {
1326                        claim.settle(&server_id);
1327                        continue;
1328                    };
1329                    let sent = client
1330                        .notify_typed::<lsp_types::DidCloseTextDocumentNotification>(
1331                            lsp_types::DidCloseTextDocumentParams {
1332                                text_document: lsp_types::TextDocumentIdentifier {
1333                                    uri: claim.uri.clone(),
1334                                },
1335                            },
1336                        )
1337                        .await;
1338                    claim.settle(&server_id);
1339                    if let Err(err) = sent {
1340                        tracing::warn!(
1341                            %server_id,
1342                            path = %claim.path.display(),
1343                            error = %ErrorChain(&err),
1344                            "failed to notify evicted document's server of textDocument/didClose; \
1345                             dropping the close"
1346                        );
1347                    }
1348                }
1349            }
1350        };
1351        if tokio::time::timeout(FLUSH_PENDING_CLOSES_DEADLINE, flush)
1352            .await
1353            .is_err()
1354        {
1355            tracing::warn!(
1356                deadline = ?FLUSH_PENDING_CLOSES_DEADLINE,
1357                "flushing evicted documents' didClose notifications hit the deadline; paths not yet \
1358                 reached and the in-flight path's unsent closes stay pending"
1359            );
1360        }
1361    }
1362
1363    /// Verify the routed server advertises support for a capability before
1364    /// dispatching a capability-gated LSP request.
1365    ///
1366    /// Production always registers an [`LspServer`] alongside its
1367    /// [`LspClient`] in the same `Translator::settle_started` step, so in
1368    /// practice a registered client always has known capabilities. If no
1369    /// `LspServer` is registered for `server_id` regardless -- a client
1370    /// registered without its server, which only happens in tests, or a
1371    /// narrow window during registration where the two maps are inserted
1372    /// under separate locks -- the capability is assumed supported rather
1373    /// than blocking the request: this mirrors the graceful-degradation
1374    /// stance used elsewhere in `Translator` when capability information is
1375    /// unavailable rather than known-absent.
1376    ///
1377    /// Note: this checks the `ServerCapabilities` snapshot captured at
1378    /// `initialize` time. A server that advertises a capability later via
1379    /// `client/registerCapability` (dynamic registration) is not reflected
1380    /// here and will be incorrectly rejected; mcpls does not currently apply
1381    /// dynamic registrations back onto the stored capabilities.
1382    ///
1383    /// # Errors
1384    ///
1385    /// Returns [`Error::CapabilityNotSupported`] if the registered server's
1386    /// `ServerCapabilities` explicitly does not advertise `capability`.
1387    pub(super) fn require_capability(
1388        &self,
1389        server_id: &ServerId,
1390        capability: Capability,
1391    ) -> Result<()> {
1392        let servers = lock_std(&self.servers);
1393        check_capability(
1394            server_id,
1395            servers
1396                .server(server_id)
1397                .map(crate::lsp::LspServer::capabilities),
1398            capability,
1399        )
1400    }
1401
1402    /// Returns true when the routed server's `codeActionProvider` capability
1403    /// advertises `resolveProvider: true` (per LSP 3.16, `CodeActionOptions`),
1404    /// meaning it will actually answer a `codeAction/resolve` follow-up
1405    /// request rather than merely receiving mcpls's client-side
1406    /// `resolve_support` advertisement (`lsp/lifecycle.rs`) with no server
1407    /// implementation behind it (#432).
1408    ///
1409    /// Unlike [`Self::require_capability`], an unregistered server (only
1410    /// possible in tests, see that method's doc comment) is treated as
1411    /// *not* supporting resolve rather than assumed-supported: resolve is a
1412    /// best-effort enhancement, so the safe default when capability
1413    /// information is unavailable is to skip the extra round-trip, not to
1414    /// risk it against a server that may not implement it.
1415    pub(super) fn code_action_resolve_supported(&self, server_id: &ServerId) -> bool {
1416        let servers = lock_std(&self.servers);
1417        matches!(
1418            servers
1419                .server(server_id)
1420                .map(crate::lsp::LspServer::capabilities)
1421                .and_then(|caps| caps.code_action_provider.as_ref()),
1422            Some(lsp_types::CodeActionProvider::CodeActionOptions(
1423                lsp_types::CodeActionOptions {
1424                    resolve_provider: Some(true),
1425                    ..
1426                }
1427            ))
1428        )
1429    }
1430
1431    /// Parse and validate a file URI, returning the validated path.
1432    ///
1433    /// # Errors
1434    ///
1435    /// Returns an error if:
1436    /// - The URI doesn't have a file:// scheme, carries an authority, or
1437    ///   otherwise cannot be converted to a path (see
1438    ///   [`crate::bridge::state::uri_to_path`])
1439    /// - The path is outside workspace boundaries
1440    pub(super) async fn parse_file_uri(&self, uri: &lsp_types::Uri) -> Result<WorkspacePath> {
1441        let path = crate::bridge::state::uri_to_path(uri).ok_or_else(|| {
1442            Error::InvalidToolParams(format!(
1443                "Invalid URI, expected an absolute file:// URI but got: {}",
1444                escape_bounded(uri.as_ref(), MAX_ECHOED_PATH_BYTES)
1445            ))
1446        })?;
1447
1448        self.validate_path(&ClientPath::try_from(path)?).await
1449    }
1450}
1451
1452#[cfg(test)]
1453mod tests {
1454    use std::collections::{HashMap, HashSet};
1455    use std::sync::Arc;
1456    use std::{assert_matches, fs};
1457
1458    use tempfile::TempDir;
1459    use tokio::io::BufReader;
1460    use tokio::sync::Mutex;
1461    use tokio::time::{Duration, timeout};
1462    use url::Url;
1463
1464    use super::*;
1465    use crate::bridge::translator::dto::Position;
1466    use crate::bridge::translator::testing::*;
1467    use crate::bridge::{NotificationCache, ResultContext, WorkspaceRoots};
1468    use crate::config::{
1469        DocumentLimit, FileExtension, FilePattern, IndexingReadyTimeoutSecs, LanguageId,
1470        LanguageMap, LspServerConfig, PositionEncodings, SearchDepth, ServerCommand, SizeLimit,
1471        TimeoutSecs, ToolRouter, ToolSet,
1472    };
1473    use crate::error::Error;
1474    use crate::lsp::LspServer;
1475    use crate::test_lsp::client_path;
1476
1477    type JsonValue = serde_json::Value;
1478
1479    #[test]
1480    fn test_indexing_gate_capability_is_the_gated_capability() {
1481        assert_eq!(
1482            IndexingGate::Required(Capability::Hover).capability(),
1483            Capability::Hover
1484        );
1485        let file_local = [
1486            (
1487                FileLocalCapability::DocumentSymbols,
1488                Capability::DocumentSymbols,
1489            ),
1490            (FileLocalCapability::FoldingRange, Capability::FoldingRange),
1491            (
1492                FileLocalCapability::SelectionRange,
1493                Capability::SelectionRange,
1494            ),
1495            (
1496                FileLocalCapability::DocumentHighlights,
1497                Capability::DocumentHighlights,
1498            ),
1499            (
1500                FileLocalCapability::FormatDocument,
1501                Capability::FormatDocument,
1502            ),
1503            (FileLocalCapability::FormatRange, Capability::FormatRange),
1504        ];
1505        for (file_local, capability) in file_local {
1506            assert_eq!(IndexingGate::FileLocal(file_local).capability(), capability);
1507        }
1508    }
1509
1510    #[test]
1511    fn test_client_for_file_server_initializing_when_expected() {
1512        // A configured/applicable language whose LSP client has not registered
1513        // yet (large solution still loading via OmniSharp) must surface
1514        // ServerInitializing — "wait and retry" — not NoServerForLanguage.
1515        let path = PathBuf::from("/ws/Assets/Scripts/Player.cs");
1516        let lang = LanguageMap::default().detect(&path);
1517        let id = ServerId::from(lang.clone());
1518
1519        let translator = Translator::new().with_router(ToolRouter::catch_all([(
1520            id.clone(),
1521            LanguageId::new(lang).unwrap(),
1522        )]));
1523        let mut expected = HashSet::new();
1524        expected.insert(id.clone());
1525        translator.set_expected_servers(expected);
1526
1527        let err = translator
1528            .client_for_file(&path, ToolKind::Hover)
1529            .unwrap_err();
1530        assert_matches!(err, Error::ServerInitializing { server_id } if server_id == id);
1531    }
1532
1533    #[test]
1534    fn test_client_for_file_no_server_when_not_expected() {
1535        // When no route is configured for the language at all, the error
1536        // stays NoServerForLanguage.
1537        let translator = Translator::new();
1538        let path = PathBuf::from("/ws/Assets/Scripts/Player.cs");
1539        let lang = translator.language_map.detect(&path);
1540
1541        let err = translator
1542            .client_for_file(&path, ToolKind::Hover)
1543            .unwrap_err();
1544        assert_matches!(err, Error::NoServerForLanguage { ref language, .. } if *language == lang);
1545    }
1546
1547    use crate::test_lsp::test_extensions;
1548
1549    fn not_found_failure(id: &ServerId, language: &str, command: &str) -> ServerSpawnFailure {
1550        ServerSpawnFailure {
1551            server_id: id.clone(),
1552            language_id: LanguageId::new(language).unwrap(),
1553            command: ServerCommand::new(command).unwrap(),
1554            reason: crate::error::StartupFailure::Spawn(Arc::new(Error::ServerNotFound {
1555                command: ServerCommand::new(command).unwrap(),
1556                io: std::io::Error::from(std::io::ErrorKind::NotFound),
1557            })),
1558        }
1559    }
1560
1561    fn router_config(
1562        language: &str,
1563        name: &str,
1564        handles: Option<Vec<ToolKind>>,
1565    ) -> LspServerConfig {
1566        LspServerConfig {
1567            language_id: LanguageId::new(language).unwrap(),
1568            command: ServerCommand::from_static("sh").into(),
1569            args: crate::config::ServerArgs::default(),
1570            env: crate::config::ServerEnv::default(),
1571            file_patterns: vec![],
1572            initialization_options: None,
1573            settings: None,
1574            timeout_seconds: TimeoutSecs::new(5).unwrap(),
1575            request_timeout_seconds: TimeoutSecs::new(5).unwrap(),
1576            heuristics: None,
1577            name: Some(ServerId::new(name).unwrap()),
1578            handles: handles.map(|tools| ToolSet::new(tools).unwrap()),
1579            indexing: crate::bridge::IndexingPolicy::Auto,
1580        }
1581    }
1582
1583    /// #527: the sole server for a language failed to spawn, so the routing
1584    /// error carries the spawn failure and its install guidance instead of
1585    /// "no LSP server configured".
1586    #[test]
1587    fn test_client_for_file_reports_startup_failure_of_sole_server() {
1588        let id = ServerId::from_static("rust");
1589        let translator = Translator::new()
1590            .with_extensions(test_extensions())
1591            .with_router(ToolRouter::catch_all([(
1592                id.clone(),
1593                LanguageId::from_static("rust"),
1594            )]));
1595        translator.record_startup_failures(&[not_found_failure(&id, "rust", "rust-analyzer")]);
1596        translator.rebind_router(&HashSet::new());
1597        translator.clear_expected_servers();
1598
1599        let err = translator
1600            .client_for_file(Path::new("/ws/main.rs"), ToolKind::Hover)
1601            .unwrap_err();
1602
1603        let Error::ServerFailedToStart(failure) = &err else {
1604            panic!("expected ServerFailedToStart, got {err:?}");
1605        };
1606        assert_eq!(failure.server_id, id);
1607        let message = err.to_string();
1608        assert!(
1609            message.contains("rustup component add rust-analyzer"),
1610            "{message}"
1611        );
1612    }
1613
1614    #[test]
1615    fn test_client_for_file_ignores_startup_failure_of_other_language() {
1616        let id = ServerId::from_static("rust");
1617        let translator = Translator::new()
1618            .with_extensions(test_extensions())
1619            .with_router(ToolRouter::catch_all([(
1620                id.clone(),
1621                LanguageId::from_static("rust"),
1622            )]));
1623        translator.record_startup_failures(&[not_found_failure(&id, "rust", "rust-analyzer")]);
1624        translator.rebind_router(&HashSet::new());
1625        translator.clear_expected_servers();
1626
1627        let err = translator
1628            .client_for_file(Path::new("/ws/script.py"), ToolKind::Hover)
1629            .unwrap_err();
1630
1631        assert_matches!(err, Error::NoServerForLanguage { .. }, "got {err:?}");
1632    }
1633
1634    /// A failed explicit server must not mask a live catch-all it was
1635    /// rebound to.
1636    #[tokio::test]
1637    async fn test_client_for_file_live_catch_all_wins_over_failed_explicit_server() {
1638        let configs = [
1639            router_config("rust", "hover-only", Some(vec![ToolKind::Hover])),
1640            router_config("rust", "catch-all", None),
1641        ];
1642        let router = ToolRouter::from_configs(configs.iter()).unwrap();
1643        let hover_id = ServerId::from_static("hover-only");
1644        let live_id = ServerId::from_static("catch-all");
1645
1646        let translator = Translator::new()
1647            .with_extensions(test_extensions())
1648            .with_router(router);
1649        let (client, _server) = fake_lsp_client();
1650        translator.register_client(live_id.clone(), client);
1651        translator.record_startup_failures(&[not_found_failure(&hover_id, "rust", "sh")]);
1652        translator.rebind_router(&HashSet::from([live_id.clone()]));
1653        translator.clear_expected_servers();
1654
1655        let (id, _client) = translator
1656            .client_for_file(Path::new("/ws/main.rs"), ToolKind::Hover)
1657            .unwrap();
1658
1659        assert_eq!(id, live_id);
1660    }
1661
1662    /// A failed catch-all is reported for a tool the live explicit server
1663    /// does not claim.
1664    #[tokio::test]
1665    async fn test_client_for_file_reports_failed_catch_all_for_unclaimed_tool() {
1666        let configs = [
1667            router_config("rust", "hover-only", Some(vec![ToolKind::Hover])),
1668            router_config("rust", "catch-all", None),
1669        ];
1670        let router = ToolRouter::from_configs(configs.iter()).unwrap();
1671        let hover_id = ServerId::from_static("hover-only");
1672        let failed_id = ServerId::from_static("catch-all");
1673
1674        let translator = Translator::new()
1675            .with_extensions(test_extensions())
1676            .with_router(router);
1677        let (client, _server) = fake_lsp_client();
1678        translator.register_client(hover_id.clone(), client);
1679        translator.record_startup_failures(&[not_found_failure(&failed_id, "rust", "sh")]);
1680        translator.rebind_router(&HashSet::from([hover_id]));
1681        translator.clear_expected_servers();
1682
1683        let err = translator
1684            .client_for_file(Path::new("/ws/main.rs"), ToolKind::Definition)
1685            .unwrap_err();
1686
1687        assert_matches!(&err, Error::ServerFailedToStart(f) if f.server_id == failed_id,
1688            "got {err:?}"
1689        );
1690    }
1691
1692    /// `.tsx` resolves through its `typescript` base language, so a failure
1693    /// of the `typescript` server is reported for it too.
1694    #[test]
1695    fn test_client_for_file_reports_startup_failure_through_react_base_language() {
1696        let id = ServerId::from_static("typescript");
1697        let translator = Translator::new()
1698            .with_extensions(test_extensions())
1699            .with_router(ToolRouter::catch_all([(
1700                id.clone(),
1701                LanguageId::from_static("typescript"),
1702            )]));
1703        translator.record_startup_failures(&[not_found_failure(
1704            &id,
1705            "typescript",
1706            "typescript-language-server",
1707        )]);
1708        translator.rebind_router(&HashSet::new());
1709        translator.clear_expected_servers();
1710
1711        let err = translator
1712            .client_for_file(Path::new("/ws/app.tsx"), ToolKind::Hover)
1713            .unwrap_err();
1714
1715        assert_matches!(&err, Error::ServerFailedToStart(f) if f.server_id == id,
1716            "got {err:?}"
1717        );
1718    }
1719
1720    #[tokio::test]
1721    async fn test_validate_path_no_workspace_roots_rejects_any_path() {
1722        let translator = Translator::new();
1723        let temp_dir = TempDir::new().unwrap();
1724        let test_file = temp_dir.path().join("test.rs");
1725        fs::write(&test_file, "fn main() {}").unwrap();
1726
1727        // With no workspace roots configured, access is rejected (fail closed)
1728        let result = translator.validate_path(&client_path(test_file)).await;
1729        assert_matches!(result, Err(Error::NoWorkspaceRoots(_)));
1730    }
1731
1732    #[tokio::test]
1733    async fn test_validate_path_within_workspace() {
1734        let mut translator = Translator::new();
1735        let temp_dir = TempDir::new().unwrap();
1736        let workspace_root = temp_dir.path().to_path_buf();
1737        translator.set_workspace_roots(WorkspaceRoots::from_paths(&[workspace_root]).unwrap());
1738
1739        let test_file = temp_dir.path().join("test.rs");
1740        fs::write(&test_file, "fn main() {}").unwrap();
1741
1742        let result = translator.validate_path(&client_path(test_file)).await;
1743        assert!(result.is_ok());
1744    }
1745
1746    #[tokio::test]
1747    async fn test_validate_path_outside_workspace() {
1748        let mut translator = Translator::new();
1749        let temp_dir1 = TempDir::new().unwrap();
1750        let temp_dir2 = TempDir::new().unwrap();
1751
1752        // Set workspace root to temp_dir1
1753        translator.set_workspace_roots(
1754            WorkspaceRoots::from_paths(&[temp_dir1.path().to_path_buf()]).unwrap(),
1755        );
1756
1757        // Create file in temp_dir2 (outside workspace)
1758        let test_file = temp_dir2.path().join("test.rs");
1759        fs::write(&test_file, "fn main() {}").unwrap();
1760
1761        let result = translator.validate_path(&client_path(test_file)).await;
1762        assert_matches!(result, Err(Error::PathOutsideWorkspace(_)));
1763    }
1764
1765    /// #533: an outside path that does not exist must be rejected as outside
1766    /// the workspace, not leak its absence as `FileIo`.
1767    #[test]
1768    fn test_validate_path_nonexistent_outside_path_is_outside_workspace() {
1769        let root = TempDir::new().unwrap();
1770        let outside = TempDir::new().unwrap();
1771        let roots = WorkspaceRoots::from_paths(&[root.path().to_path_buf()]).unwrap();
1772
1773        let result = roots
1774            .validate_blocking(&client_path(outside.path().join("missing.rs")))
1775            .map(crate::bridge::WorkspacePath::into_path_buf);
1776
1777        assert_matches!(result, Err(Error::PathOutsideWorkspace(_)));
1778    }
1779
1780    #[test]
1781    fn test_validate_path_dotdot_escape_is_outside_workspace() {
1782        let root = TempDir::new().unwrap();
1783        let roots = WorkspaceRoots::from_paths(&[root.path().to_path_buf()]).unwrap();
1784
1785        let result = roots
1786            .validate_blocking(&client_path(root.path().join("../escape.rs")))
1787            .map(crate::bridge::WorkspacePath::into_path_buf);
1788
1789        assert_matches!(result, Err(Error::PathOutsideWorkspace(_)));
1790    }
1791
1792    #[test]
1793    fn test_validate_path_dot_segments_inside_root_are_accepted() {
1794        let root = TempDir::new().unwrap();
1795        fs::create_dir(root.path().join("a")).unwrap();
1796        fs::write(root.path().join("b.rs"), "").unwrap();
1797        let roots = WorkspaceRoots::from_paths(&[root.path().to_path_buf()]).unwrap();
1798
1799        let result = roots
1800            .validate_blocking(&client_path(root.path().join("./a/../b.rs")))
1801            .map(crate::bridge::WorkspacePath::into_path_buf);
1802
1803        assert_eq!(
1804            result.unwrap(),
1805            dunce::canonicalize(root.path().join("b.rs")).unwrap()
1806        );
1807    }
1808
1809    /// A client naming the workspace through a symlinked alias of the
1810    /// canonical root is admitted when the alias was precomputed.
1811    #[cfg(unix)]
1812    #[test]
1813    fn test_validate_path_symlink_alias_of_root_is_accepted() {
1814        let AliasFixture {
1815            dir: _dir,
1816            real,
1817            alias,
1818            roots,
1819        } = alias_fixture();
1820        fs::write(real.join("a.rs"), "").unwrap();
1821
1822        let result = roots
1823            .validate_blocking(&client_path(alias.join("a.rs")))
1824            .map(crate::bridge::WorkspacePath::into_path_buf);
1825
1826        assert_eq!(
1827            result.unwrap(),
1828            dunce::canonicalize(real.join("a.rs")).unwrap()
1829        );
1830    }
1831
1832    /// `..` right after an alias root leaves both the alias and the canonical
1833    /// root, so the pre-check rejects it.
1834    #[cfg(unix)]
1835    #[test]
1836    fn test_validate_path_dotdot_after_alias_root_is_outside_workspace() {
1837        let AliasFixture {
1838            dir, alias, roots, ..
1839        } = alias_fixture();
1840        fs::write(dir.path().join("outside.rs"), "").unwrap();
1841
1842        let result = roots
1843            .validate_blocking(&client_path(alias.join("../outside.rs")))
1844            .map(crate::bridge::WorkspacePath::into_path_buf);
1845
1846        assert_matches!(result, Err(Error::PathOutsideWorkspace(_)));
1847    }
1848
1849    /// Inside an alias root the pre-check admits the path, so a missing file
1850    /// is a plain I/O error.
1851    #[cfg(unix)]
1852    #[test]
1853    fn test_validate_path_missing_file_under_alias_root_is_file_io() {
1854        let AliasFixture {
1855            dir: _dir,
1856            alias,
1857            roots,
1858            ..
1859        } = alias_fixture();
1860
1861        let result = roots
1862            .validate_blocking(&client_path(alias.join("missing.rs")))
1863            .map(crate::bridge::WorkspacePath::into_path_buf);
1864
1865        assert_matches!(result, Err(Error::FileIo { .. }), "{result:?}");
1866    }
1867
1868    /// A symlink inside an alias root that escapes the workspace is still
1869    /// rejected by the canonical check.
1870    #[cfg(unix)]
1871    #[test]
1872    fn test_validate_path_escaping_symlink_under_alias_root_is_outside_workspace() {
1873        let AliasFixture {
1874            dir: _dir,
1875            real,
1876            alias,
1877            roots,
1878        } = alias_fixture();
1879        let outside = TempDir::new().unwrap();
1880        fs::write(outside.path().join("secret.rs"), "").unwrap();
1881        std::os::unix::fs::symlink(outside.path(), real.join("link")).unwrap();
1882
1883        let result = roots
1884            .validate_blocking(&client_path(alias.join("link/secret.rs")))
1885            .map(crate::bridge::WorkspacePath::into_path_buf);
1886
1887        assert_matches!(result, Err(Error::PathOutsideWorkspace(_)));
1888    }
1889
1890    /// A symlink inside the root pointing outside passes the lexical
1891    /// pre-check but must still fail the authoritative canonical check.
1892    #[cfg(unix)]
1893    #[test]
1894    fn test_validate_path_symlink_escaping_root_is_outside_workspace() {
1895        let root = TempDir::new().unwrap();
1896        let outside = TempDir::new().unwrap();
1897        fs::write(outside.path().join("secret.rs"), "").unwrap();
1898        std::os::unix::fs::symlink(outside.path(), root.path().join("link")).unwrap();
1899        let roots = WorkspaceRoots::from_paths(&[root.path().to_path_buf()]).unwrap();
1900
1901        let result = roots
1902            .validate_blocking(&client_path(root.path().join("link/secret.rs")))
1903            .map(crate::bridge::WorkspacePath::into_path_buf);
1904
1905        assert_matches!(result, Err(Error::PathOutsideWorkspace(_)));
1906    }
1907
1908    /// Regression guard: `prepare_gated_document`'s `&str` overload (used by
1909    /// `handle_hover` and nearly every other gated handler) must still
1910    /// reject an out-of-workspace path end-to-end, i.e.
1911    /// `resolve_validated_client_for_file` must validate independently
1912    /// rather than ever delegating to the `&Path` overload (whose
1913    /// `resolve_validated_client_for_path` sibling trusts its caller to have
1914    /// already validated and does not check workspace roots itself). Pins
1915    /// down a near-miss caught during the #423/#425 refactor, where
1916    /// `prepare_gated_document` briefly delegated through the `&Path`
1917    /// overload and would have silently skipped this check for every
1918    /// `&str`-based handler.
1919    #[tokio::test]
1920    async fn test_handle_hover_blocked_when_path_outside_workspace() {
1921        let workspace_dir = TempDir::new().unwrap();
1922        let outside_dir = TempDir::new().unwrap();
1923        let server_id = ServerId::from_static("rust");
1924        let (translator, _server) = translator_with_capabilities(
1925            &workspace_dir,
1926            &server_id,
1927            lsp_types::ServerCapabilities {
1928                hover_provider: Some(lsp_types::HoverProvider::Bool(true)),
1929                ..Default::default()
1930            },
1931        );
1932
1933        let outside_path = outside_dir.path().join("outside.rs");
1934        fs::write(&outside_path, "fn outside() {}").unwrap();
1935
1936        let result = translator
1937            .handle_hover(
1938                client_path(outside_path.to_string_lossy().into_owned()),
1939                pos(1, 1),
1940            )
1941            .await;
1942
1943        assert_matches!(result, Err(Error::PathOutsideWorkspace(_)));
1944    }
1945
1946    #[tokio::test]
1947    async fn test_parse_file_uri_invalid_scheme() {
1948        let translator = Translator::new();
1949        let uri: lsp_types::Uri = lsp_types::Uri::from("http://example.com/file.rs");
1950        let result = translator.parse_file_uri(&uri).await;
1951        assert_matches!(result, Err(Error::InvalidToolParams(_)));
1952    }
1953
1954    #[tokio::test]
1955    async fn test_parse_file_uri_valid_scheme() {
1956        let mut translator = Translator::new();
1957        let temp_dir = TempDir::new().unwrap();
1958        translator.set_workspace_roots(
1959            WorkspaceRoots::from_paths(&[temp_dir.path().to_path_buf()]).unwrap(),
1960        );
1961        let test_file = temp_dir.path().join("test.rs");
1962        fs::write(&test_file, "fn main() {}").unwrap();
1963
1964        // Use url crate for cross-platform file URI creation
1965        let file_url = Url::from_file_path(&test_file).unwrap();
1966        let uri: lsp_types::Uri = lsp_types::Uri::from(file_url.as_str());
1967        let result = translator.parse_file_uri(&uri).await;
1968        assert!(result.is_ok());
1969    }
1970
1971    /// #411 regression: a raw-sliced (non-decoded) URI keeps `%20`/`%C3%A9`
1972    /// literally in the path, so `canonicalize()` fails with `ENOENT` for
1973    /// any file whose path contains a space or a non-ASCII character, even
1974    /// though the file exists. `parse_file_uri` must percent-decode first.
1975    #[tokio::test]
1976    async fn test_parse_file_uri_percent_decodes_space_and_non_ascii() {
1977        let mut translator = Translator::new();
1978        let temp_dir = TempDir::new().unwrap();
1979        translator.set_workspace_roots(
1980            WorkspaceRoots::from_paths(&[temp_dir.path().to_path_buf()]).unwrap(),
1981        );
1982        let test_file = temp_dir.path().join("my file café.rs");
1983        fs::write(&test_file, "fn main() {}").unwrap();
1984
1985        let file_url = Url::from_file_path(&test_file).unwrap();
1986        assert!(
1987            file_url.as_str().contains("%20"),
1988            "test fixture must exercise percent-encoding"
1989        );
1990        let uri: lsp_types::Uri = lsp_types::Uri::from(file_url.as_str());
1991        let result = translator.parse_file_uri(&uri).await.unwrap();
1992        assert_eq!(result.as_path(), dunce::canonicalize(&test_file).unwrap());
1993    }
1994
1995    /// #411: an authority-bearing `file://` URI (e.g. `file://host/path`)
1996    /// must be rejected, not silently resolved to a path relative to the
1997    /// process's cwd -- see `uri_to_path`'s authority check.
1998    #[tokio::test]
1999    async fn test_parse_file_uri_rejects_authority() {
2000        let translator = Translator::new();
2001        let uri: lsp_types::Uri = lsp_types::Uri::from("file://host/some/path.rs");
2002        let result = translator.parse_file_uri(&uri).await;
2003        assert_matches!(result, Err(Error::InvalidToolParams(_)));
2004    }
2005
2006    #[test]
2007    fn test_client_for_file_uses_custom_extension() {
2008        let temp_dir = TempDir::new().unwrap();
2009        let test_file = temp_dir.path().join("script.nu");
2010        fs::write(&test_file, "echo hello").unwrap();
2011
2012        let mut extension_map = HashMap::new();
2013        extension_map.insert(
2014            FileExtension::from_static("nu"),
2015            LanguageId::from_static("nushell"),
2016        );
2017
2018        let translator = Translator::new().with_extensions(extension_map);
2019
2020        let result = translator.client_for_file(&test_file, ToolKind::Hover);
2021
2022        assert!(result.is_err());
2023        if let Err(Error::NoServerForLanguage { language, file, .. }) = result {
2024            assert_eq!(language, "nushell");
2025            assert_eq!(file, FileKey::Extension(FileExtension::from_static("nu")));
2026        } else {
2027            panic!("Expected NoServerForLanguage(nushell) error");
2028        }
2029    }
2030
2031    #[test]
2032    fn test_client_for_file_reports_an_extensionless_name() {
2033        let mut language_map = LanguageMap::default();
2034        language_map.insert(
2035            crate::config::PatternTarget::Name(crate::config::FileName::from_static("Makefile")),
2036            LanguageId::from_static("make"),
2037        );
2038        let translator = Translator::new().with_extensions(language_map);
2039
2040        let result = translator.client_for_file(Path::new("/ws/Makefile"), ToolKind::Hover);
2041        assert_matches!(
2042            result,
2043            Err(Error::NoServerForLanguage { language, .. }) if language == "make"
2044        );
2045
2046        let result = translator.client_for_file(Path::new("/ws/Dockerfile"), ToolKind::Hover);
2047        assert_matches!(
2048            result,
2049            Err(Error::NoServerForLanguage { file: FileKey::Name(name), .. }) if name == "Dockerfile"
2050        );
2051    }
2052
2053    #[test]
2054    fn test_client_for_file_falls_back_to_default() {
2055        let temp_dir = TempDir::new().unwrap();
2056        let test_file = temp_dir.path().join("unknown.xyz");
2057        fs::write(&test_file, "content").unwrap();
2058
2059        let mut extension_map = HashMap::new();
2060        extension_map.insert(
2061            FileExtension::from_static("rs"),
2062            LanguageId::from_static("rust"),
2063        );
2064
2065        let translator = Translator::new()
2066            .with_extensions(extension_map)
2067            .with_file_patterns([FilePattern::from_static("**/*.rs")]);
2068
2069        let result = translator.client_for_file(&test_file, ToolKind::Hover);
2070
2071        assert!(result.is_err());
2072        if let Err(Error::NoServerForLanguage {
2073            language,
2074            file,
2075            patterns,
2076        }) = result
2077        {
2078            assert_eq!(language, "plaintext");
2079            assert_eq!(file, FileKey::Extension(FileExtension::from_static("xyz")));
2080            assert_eq!(&*patterns, [FilePattern::from_static("**/*.rs")]);
2081        } else {
2082            panic!("Expected NoServerForLanguage(plaintext) error");
2083        }
2084    }
2085
2086    #[test]
2087    fn test_client_for_file_routes_tsx_to_typescript_server() {
2088        let temp_dir = TempDir::new().unwrap();
2089        let test_file = temp_dir.path().join("component.tsx");
2090        fs::write(&test_file, "export const Component = () => <div />").unwrap();
2091
2092        let mut extension_map = HashMap::new();
2093        extension_map.insert(
2094            FileExtension::from_static("tsx"),
2095            LanguageId::from_static("typescriptreact"),
2096        );
2097
2098        let translator = Translator::new()
2099            .with_extensions(extension_map)
2100            .with_router(ToolRouter::catch_all([(
2101                ServerId::from_static("typescript"),
2102                LanguageId::from_static("typescript"),
2103            )]));
2104        translator.register_client(
2105            ServerId::from_static("typescript"),
2106            LspClient::new(crate::config::LspServerConfig::typescript()),
2107        );
2108
2109        let (_id, client) = translator
2110            .client_for_file(&test_file, ToolKind::Hover)
2111            .unwrap();
2112        assert_eq!(client.language_id(), "typescript");
2113    }
2114
2115    #[test]
2116    fn test_client_for_file_prefers_exact_react_server() {
2117        let temp_dir = TempDir::new().unwrap();
2118        let test_file = temp_dir.path().join("component.tsx");
2119        fs::write(&test_file, "export const Component = () => <div />").unwrap();
2120
2121        let mut extension_map = HashMap::new();
2122        extension_map.insert(
2123            FileExtension::from_static("tsx"),
2124            LanguageId::from_static("typescriptreact"),
2125        );
2126
2127        let typescript_react_config = crate::config::LspServerConfig {
2128            language_id: LanguageId::from_static("typescriptreact"),
2129            command: ServerCommand::from_static("typescript-language-server").into(),
2130            args: crate::config::ServerArgs::try_from(vec!["--stdio".to_string()]).unwrap(),
2131            env: crate::config::ServerEnv::default(),
2132            file_patterns: vec![FilePattern::from_static("**/*.tsx")],
2133            initialization_options: None,
2134            settings: None,
2135            timeout_seconds: TimeoutSecs::new(30).unwrap(),
2136            request_timeout_seconds: TimeoutSecs::new(30).unwrap(),
2137            heuristics: None,
2138            name: None,
2139            handles: None,
2140            indexing: crate::bridge::IndexingPolicy::Auto,
2141        };
2142
2143        let translator = Translator::new()
2144            .with_extensions(extension_map)
2145            .with_router(ToolRouter::catch_all([
2146                (
2147                    ServerId::from_static("typescript"),
2148                    LanguageId::from_static("typescript"),
2149                ),
2150                (
2151                    ServerId::from_static("typescriptreact"),
2152                    LanguageId::from_static("typescriptreact"),
2153                ),
2154            ]));
2155        translator.register_client(
2156            ServerId::from_static("typescript"),
2157            LspClient::new(crate::config::LspServerConfig::typescript()),
2158        );
2159        translator.register_client(
2160            ServerId::from(LanguageId::from_static("typescriptreact")),
2161            LspClient::new(typescript_react_config),
2162        );
2163
2164        let (_id, client) = translator
2165            .client_for_file(&test_file, ToolKind::Hover)
2166            .unwrap();
2167        assert_eq!(client.language_id(), "typescriptreact");
2168    }
2169
2170    /// #359: a registered server's route id stays resolvable (it is what the
2171    /// push-degraded flag is keyed on) across a respawn, which swaps the
2172    /// client behind the id without unregistering it.
2173    #[test]
2174    fn test_diagnostics_route_for_path_live_when_registered() {
2175        let id = ServerId::from_static("rust");
2176        let translator = Translator::new()
2177            .with_extensions(test_extensions())
2178            .with_router(ToolRouter::catch_all([(
2179                id.clone(),
2180                LanguageId::from_static("rust"),
2181            )]));
2182        translator.register_client(
2183            id.clone(),
2184            LspClient::new(crate::config::LspServerConfig::rust_analyzer()),
2185        );
2186
2187        let route = translator.diagnostics_route_for_path(Path::new("/ws/main.rs"));
2188
2189        assert_matches!(&route, DiagnosticsRoute::Live(live) if *live == id,
2190            "{route:?}"
2191        );
2192        assert_eq!(route.server_id(), Some(&id));
2193        assert_eq!(route.into_read_result().unwrap(), Some(id));
2194    }
2195
2196    /// A routed server that is expected but not yet registered reads as
2197    /// retryable "still starting", not as an empty cache.
2198    #[test]
2199    fn test_diagnostics_route_for_path_initializing_while_expected() {
2200        let id = ServerId::from_static("rust");
2201        let translator = Translator::new()
2202            .with_extensions(test_extensions())
2203            .with_router(ToolRouter::catch_all([(
2204                id.clone(),
2205                LanguageId::from_static("rust"),
2206            )]));
2207        translator.set_expected_servers(HashSet::from([id.clone()]));
2208
2209        let route = translator.diagnostics_route_for_path(Path::new("/ws/main.rs"));
2210
2211        assert_matches!(&route, DiagnosticsRoute::Initializing(i) if *i == id,
2212            "{route:?}"
2213        );
2214        assert_eq!(route.server_id(), Some(&id));
2215        let err = route.into_read_result().unwrap_err();
2216        assert_matches!(&err, Error::ServerInitializing { server_id } if *server_id == id,
2217            "{err:?}"
2218        );
2219    }
2220
2221    /// #535: a server that failed to start is reported, not read as empty.
2222    #[test]
2223    fn test_diagnostics_route_for_path_failed_to_start() {
2224        let id = ServerId::from_static("rust");
2225        let translator = Translator::new()
2226            .with_extensions(test_extensions())
2227            .with_router(ToolRouter::catch_all([(
2228                id.clone(),
2229                LanguageId::from_static("rust"),
2230            )]));
2231        translator.record_startup_failures(&[not_found_failure(&id, "rust", "rust-analyzer")]);
2232        translator.rebind_router(&HashSet::new());
2233        translator.clear_expected_servers();
2234
2235        let route = translator.diagnostics_route_for_path(Path::new("/ws/main.rs"));
2236
2237        assert!(route.is_failed(), "{route:?}");
2238        assert_eq!(route.server_id(), None);
2239        let err = route.into_read_result().unwrap_err();
2240        assert_matches!(&err, Error::ServerFailedToStart(f) if f.server_id == id,
2241            "{err:?}"
2242        );
2243    }
2244
2245    /// A language with no configured route is unrouted, and the failure of an
2246    /// unrelated language's server does not leak into it.
2247    #[test]
2248    fn test_diagnostics_route_for_path_unrouted_reads_empty() {
2249        let id = ServerId::from_static("rust");
2250        let translator = Translator::new()
2251            .with_extensions(test_extensions())
2252            .with_router(ToolRouter::catch_all([(
2253                id.clone(),
2254                LanguageId::from_static("rust"),
2255            )]));
2256        translator.record_startup_failures(&[not_found_failure(&id, "rust", "rust-analyzer")]);
2257        translator.rebind_router(&HashSet::new());
2258        translator.clear_expected_servers();
2259
2260        let route = translator.diagnostics_route_for_path(Path::new("/ws/script.py"));
2261
2262        assert_matches!(route, DiagnosticsRoute::Unrouted, "{route:?}");
2263        assert_eq!(route.into_read_result().unwrap(), None);
2264    }
2265
2266    /// A router entry naming a server that is neither registered nor expected
2267    /// is logged and treated as unrouted.
2268    #[test]
2269    fn test_diagnostics_route_for_path_dangling_is_unrouted() {
2270        let id = ServerId::from_static("rust");
2271        let translator = Translator::new()
2272            .with_extensions(test_extensions())
2273            .with_router(ToolRouter::catch_all([(
2274                id,
2275                LanguageId::from_static("rust"),
2276            )]));
2277
2278        let route = translator.diagnostics_route_for_path(Path::new("/ws/main.rs"));
2279
2280        assert_matches!(route, DiagnosticsRoute::Unrouted, "{route:?}");
2281    }
2282
2283    /// A live catch-all the router was rebound to beats a failed explicit
2284    /// server, as for `client_for_file`.
2285    #[tokio::test]
2286    async fn test_diagnostics_route_for_path_live_catch_all_wins_over_failed_explicit_server() {
2287        let configs = [
2288            router_config("rust", "diag-only", Some(vec![ToolKind::Diagnostics])),
2289            router_config("rust", "catch-all", None),
2290        ];
2291        let router = ToolRouter::from_configs(configs.iter()).unwrap();
2292        let failed_id = ServerId::from_static("diag-only");
2293        let live_id = ServerId::from_static("catch-all");
2294        let translator = Translator::new()
2295            .with_extensions(test_extensions())
2296            .with_router(router);
2297        let (client, _server) = fake_lsp_client();
2298        translator.register_client(live_id.clone(), client);
2299        translator.record_startup_failures(&[not_found_failure(&failed_id, "rust", "sh")]);
2300        translator.rebind_router(&HashSet::from([live_id.clone()]));
2301        translator.clear_expected_servers();
2302
2303        let route = translator.diagnostics_route_for_path(Path::new("/ws/main.rs"));
2304
2305        assert_matches!(&route, DiagnosticsRoute::Live(id) if *id == live_id,
2306            "{route:?}"
2307        );
2308    }
2309
2310    /// The failure is found through the React base language, like routing.
2311    #[test]
2312    fn test_diagnostics_route_for_path_reports_failure_through_react_base_language() {
2313        let id = ServerId::from_static("typescript");
2314        let translator = Translator::new()
2315            .with_extensions(test_extensions())
2316            .with_router(ToolRouter::catch_all([(
2317                id.clone(),
2318                LanguageId::from_static("typescript"),
2319            )]));
2320        translator.record_startup_failures(&[not_found_failure(
2321            &id,
2322            "typescript",
2323            "typescript-language-server",
2324        )]);
2325        translator.rebind_router(&HashSet::new());
2326        translator.clear_expected_servers();
2327
2328        let route = translator.diagnostics_route_for_path(Path::new("/ws/app.tsx"));
2329
2330        assert_matches!(&route, DiagnosticsRoute::FailedToStart(f) if f.server_id == id,
2331            "{route:?}"
2332        );
2333    }
2334
2335    /// A failed server whose route excludes diagnostics is not the diagnostics
2336    /// route, so its failure must not surface for diagnostics reads.
2337    #[test]
2338    fn test_diagnostics_route_for_path_ignores_failure_of_server_without_diagnostics_route() {
2339        let configs = [router_config(
2340            "rust",
2341            "hover-only",
2342            Some(vec![ToolKind::Hover]),
2343        )];
2344        let router = ToolRouter::from_configs(configs.iter()).unwrap();
2345        let failed = ServerId::from_static("hover-only");
2346        let translator = Translator::new()
2347            .with_extensions(test_extensions())
2348            .with_router(router);
2349        translator.record_startup_failures(&[not_found_failure(&failed, "rust", "sh")]);
2350        translator.rebind_router(&HashSet::new());
2351        translator.clear_expected_servers();
2352
2353        let route = translator.diagnostics_route_for_path(Path::new("/ws/main.rs"));
2354
2355        assert_matches!(route, DiagnosticsRoute::Unrouted, "{route:?}");
2356    }
2357
2358    /// The live pull path (`get_diagnostics`) also reports the failure.
2359    #[tokio::test]
2360    async fn test_handle_diagnostics_reports_failed_server_start() {
2361        let dir = TempDir::new().unwrap();
2362        let file = dunce::canonicalize(dir.path()).unwrap().join("main.rs");
2363        fs::write(&file, "fn main() {}").unwrap();
2364        let id = ServerId::from_static("rust");
2365        let mut translator = Translator::new()
2366            .with_extensions(test_extensions())
2367            .with_router(ToolRouter::catch_all([(
2368                id.clone(),
2369                LanguageId::from_static("rust"),
2370            )]));
2371        translator
2372            .set_workspace_roots(WorkspaceRoots::from_paths(&[dir.path().to_path_buf()]).unwrap());
2373        translator.record_startup_failures(&[not_found_failure(&id, "rust", "sh")]);
2374        translator.rebind_router(&HashSet::new());
2375        translator.clear_expected_servers();
2376        let cache = Mutex::new(crate::bridge::NotificationCache::new());
2377
2378        let err = translator
2379            .handle_diagnostics(
2380                client_path(file.to_string_lossy().into_owned()),
2381                ResultContext::None,
2382                &cache,
2383            )
2384            .await
2385            .unwrap_err();
2386
2387        assert_matches!(&err, Error::ServerFailedToStart(f) if f.server_id == id,
2388            "got {err:?}"
2389        );
2390    }
2391
2392    /// `dir/real` (the canonical root) with `dir/alias` symlinked to it, and
2393    /// roots admitting both spellings.
2394    #[cfg(unix)]
2395    struct AliasFixture {
2396        dir: TempDir,
2397        real: PathBuf,
2398        alias: PathBuf,
2399        roots: WorkspaceRoots,
2400    }
2401
2402    #[cfg(unix)]
2403    fn alias_fixture() -> AliasFixture {
2404        let dir = TempDir::new().unwrap();
2405        let real = dir.path().join("real");
2406        fs::create_dir(&real).unwrap();
2407        let alias = dir.path().join("alias");
2408        std::os::unix::fs::symlink(&real, &alias).unwrap();
2409        let roots = WorkspaceRoots::from_paths(std::slice::from_ref(&alias)).unwrap();
2410        AliasFixture {
2411            dir,
2412            real,
2413            alias,
2414            roots,
2415        }
2416    }
2417
2418    #[test]
2419    fn test_client_for_file_routes_jsx_to_javascript_server() {
2420        let temp_dir = TempDir::new().unwrap();
2421        let test_file = temp_dir.path().join("component.jsx");
2422        fs::write(&test_file, "export const Component = () => <div />").unwrap();
2423
2424        let mut extension_map = HashMap::new();
2425        extension_map.insert(
2426            FileExtension::from_static("jsx"),
2427            LanguageId::from_static("javascriptreact"),
2428        );
2429
2430        let javascript_config = crate::config::LspServerConfig {
2431            language_id: LanguageId::from_static("javascript"),
2432            command: ServerCommand::from_static("typescript-language-server").into(),
2433            args: crate::config::ServerArgs::try_from(vec!["--stdio".to_string()]).unwrap(),
2434            env: crate::config::ServerEnv::default(),
2435            file_patterns: vec![
2436                FilePattern::from_static("**/*.js"),
2437                FilePattern::from_static("**/*.jsx"),
2438            ],
2439            initialization_options: None,
2440            settings: None,
2441            timeout_seconds: TimeoutSecs::new(30).unwrap(),
2442            request_timeout_seconds: TimeoutSecs::new(30).unwrap(),
2443            heuristics: None,
2444            name: None,
2445            handles: None,
2446            indexing: crate::bridge::IndexingPolicy::Auto,
2447        };
2448        let translator = Translator::new()
2449            .with_extensions(extension_map)
2450            .with_router(ToolRouter::catch_all([(
2451                ServerId::from_static("javascript"),
2452                LanguageId::from_static("javascript"),
2453            )]));
2454        translator.register_client(
2455            ServerId::from_static("javascript"),
2456            LspClient::new(javascript_config),
2457        );
2458
2459        let (_id, client) = translator
2460            .client_for_file(&test_file, ToolKind::Hover)
2461            .unwrap();
2462        assert_eq!(client.language_id(), "javascript");
2463    }
2464
2465    #[tokio::test]
2466    async fn test_serve_initializes_translator_with_extensions() {
2467        use crate::config::{LanguageExtensionMapping, WorkspaceConfig};
2468
2469        let language_extensions = vec![
2470            LanguageExtensionMapping {
2471                extensions: vec![FileExtension::from_static("nu")],
2472                language_id: LanguageId::from_static("nushell"),
2473            },
2474            LanguageExtensionMapping {
2475                extensions: vec![FileExtension::from_static("rs")],
2476                language_id: LanguageId::from_static("rust"),
2477            },
2478        ];
2479
2480        let config = crate::config::ServerConfig {
2481            mcp: crate::config::McpConfig::default(),
2482            workspace: WorkspaceConfig {
2483                roots: vec![crate::config::ConfiguredRoot::new("/tmp/test-workspace").unwrap()],
2484                position_encodings: PositionEncodings::DEFAULT,
2485                language_extensions: language_extensions.clone(),
2486                heuristics_max_depth: SearchDepth::DEFAULT,
2487                max_documents: DocumentLimit::DEFAULT,
2488                max_file_size: SizeLimit::DEFAULT,
2489                indexing_ready_timeout_seconds: IndexingReadyTimeoutSecs::DEFAULT,
2490                max_concurrent_server_starts: crate::config::ServerStartConcurrency::DEFAULT,
2491            },
2492            lsp_servers: vec![],
2493            project_config_status: crate::ProjectConfigStatus::NotIgnored,
2494            workspace_trust: crate::config::WorkspaceTrust::default(),
2495        };
2496
2497        let language_map = config.build_effective_language_map();
2498        assert_eq!(language_map.detect(Path::new("a.nu")), "nushell");
2499        assert_eq!(language_map.detect(Path::new("a.rs")), "rust");
2500
2501        // serve() starts in protocol-only mode when no LSP servers are configured;
2502        // it may return a transport error but must not report a startup failure.
2503        let result = crate::serve(config).await;
2504        if let Err(ref err) = result {
2505            assert!(
2506                !matches!(err, crate::error::Error::AllServersFailedToInit { .. }),
2507                "serve() must not report init failures for empty lsp_servers config"
2508            );
2509        }
2510    }
2511
2512    #[tokio::test]
2513    async fn test_concurrent_handlers_on_different_files_do_not_serialize() {
2514        // Before the fix, Translator was shared as Arc<Mutex<Translator>>, so
2515        // handling one LSP request held that lock across the `.await` on the
2516        // response -- blocking every other tool call, even for a completely
2517        // different file and language server, until the first request
2518        // completed or timed out (up to 30s). With interior mutability, a
2519        // concurrent call for a different file must complete without waiting
2520        // on an unrelated in-flight request.
2521        let dir = TempDir::new().unwrap();
2522        let mut extensions = HashMap::new();
2523        extensions.insert(
2524            FileExtension::from_static("aa"),
2525            LanguageId::from_static("lang_a"),
2526        );
2527        extensions.insert(
2528            FileExtension::from_static("bb"),
2529            LanguageId::from_static("lang_b"),
2530        );
2531
2532        let mut translator =
2533            Translator::new()
2534                .with_extensions(extensions)
2535                .with_router(ToolRouter::catch_all([
2536                    (
2537                        ServerId::from_static("lang_a"),
2538                        LanguageId::from_static("lang_a"),
2539                    ),
2540                    (
2541                        ServerId::from_static("lang_b"),
2542                        LanguageId::from_static("lang_b"),
2543                    ),
2544                ]));
2545        translator
2546            .set_workspace_roots(WorkspaceRoots::from_paths(&[dir.path().to_path_buf()]).unwrap());
2547
2548        let (client_a, mut server_a) = fake_lsp_client();
2549        let (client_b, mut server_b) = fake_lsp_client();
2550        translator.register_client(ServerId::from_static("lang_a"), client_a);
2551        translator.register_client(ServerId::from_static("lang_b"), client_b);
2552
2553        let path_a = dir.path().join("file.aa");
2554        let path_b = dir.path().join("file.bb");
2555        fs::write(&path_a, "content a").unwrap();
2556        fs::write(&path_b, "content b").unwrap();
2557
2558        let translator = Arc::new(translator);
2559
2560        // `server_a` is never given a response, simulating a slow server. If
2561        // any translator-held lock still spanned the LSP round trip, this
2562        // task blocking forever would also block the "fast" call below.
2563        let slow = {
2564            let translator = Arc::clone(&translator);
2565            let path = path_a.to_string_lossy().to_string();
2566            tokio::spawn(async move {
2567                translator
2568                    .handle_hover(client_path(path), Position::at(1, 1))
2569                    .await
2570            })
2571        };
2572
2573        // Wait for the slow task to actually reach its LSP request (i.e. the
2574        // request bytes were written to the wire) before treating it as
2575        // "in-flight", so the test doesn't race the spawned task's startup.
2576        let mut wire_a = BufReader::new(&mut server_a.write_stdout);
2577        let opened_a = read_framed_message(&mut wire_a).await;
2578        assert_eq!(opened_a["method"], "textDocument/didOpen");
2579        let hover_request_a = read_framed_message(&mut wire_a).await;
2580        assert_eq!(hover_request_a["method"], "textDocument/hover");
2581
2582        // The fast path: a concurrent call for a different file/server.
2583        let fast = {
2584            let translator = Arc::clone(&translator);
2585            let path = path_b.to_string_lossy().to_string();
2586            tokio::spawn(async move {
2587                translator
2588                    .handle_hover(client_path(path), Position::at(1, 1))
2589                    .await
2590            })
2591        };
2592
2593        let mut wire_b = BufReader::new(&mut server_b.write_stdout);
2594        let opened_b = read_framed_message(&mut wire_b).await;
2595        assert_eq!(opened_b["method"], "textDocument/didOpen");
2596        let hover_request_b = read_framed_message(&mut wire_b).await;
2597        assert_eq!(hover_request_b["method"], "textDocument/hover");
2598        write_response(
2599            &mut server_b.read_half_stdin,
2600            &hover_request_b["id"],
2601            JsonValue::Null,
2602        )
2603        .await;
2604
2605        let fast_result = timeout(Duration::from_secs(2), fast)
2606            .await
2607            .expect("fast call must not be blocked by the slow in-flight request")
2608            .unwrap();
2609        assert!(fast_result.is_ok());
2610
2611        assert!(
2612            !slow.is_finished(),
2613            "slow call should still be waiting on its (never-sent) response"
2614        );
2615        slow.abort();
2616    }
2617
2618    #[tokio::test]
2619    async fn test_concurrent_ensure_open_same_path_sends_single_did_open() {
2620        // Regression test: concurrent handler calls for the SAME path must
2621        // serialize on that path's `ensure_open` lock (see `DocumentTracker::lock_path`)
2622        // so they can't both observe "not open yet" and both send didOpen.
2623        let dir = TempDir::new().unwrap();
2624        let mut extensions = HashMap::new();
2625        extensions.insert(
2626            FileExtension::from_static("aa"),
2627            LanguageId::from_static("lang_a"),
2628        );
2629
2630        let mut translator =
2631            Translator::new()
2632                .with_extensions(extensions)
2633                .with_router(ToolRouter::catch_all([(
2634                    ServerId::from_static("lang_a"),
2635                    LanguageId::from_static("lang_a"),
2636                )]));
2637        translator
2638            .set_workspace_roots(WorkspaceRoots::from_paths(&[dir.path().to_path_buf()]).unwrap());
2639
2640        let (client, mut server) = fake_lsp_client();
2641        translator.register_client(ServerId::from_static("lang_a"), client);
2642
2643        let path = dir.path().join("file.aa");
2644        fs::write(&path, "content").unwrap();
2645
2646        let concurrent_calls = 4;
2647
2648        let translator = Arc::new(translator);
2649        let path_str = path.to_string_lossy().to_string();
2650
2651        let handles: Vec<_> = (0..concurrent_calls)
2652            .map(|_| {
2653                let translator = Arc::clone(&translator);
2654                let path_str = path_str.clone();
2655                tokio::spawn(async move {
2656                    translator
2657                        .handle_hover(client_path(path_str), Position::at(1, 1))
2658                        .await
2659                })
2660            })
2661            .collect();
2662
2663        let mut wire = BufReader::new(&mut server.write_stdout);
2664        let opened = read_framed_message(&mut wire).await;
2665        assert_eq!(opened["method"], "textDocument/didOpen");
2666
2667        for _ in 0..concurrent_calls {
2668            let request = read_framed_message(&mut wire).await;
2669            assert_eq!(
2670                request["method"], "textDocument/hover",
2671                "no second didOpen must appear ahead of the hover requests"
2672            );
2673            write_response(&mut server.read_half_stdin, &request["id"], JsonValue::Null).await;
2674        }
2675
2676        for handle in handles {
2677            let result = timeout(Duration::from_secs(2), handle)
2678                .await
2679                .expect("handler call should not hang")
2680                .unwrap();
2681            assert!(result.is_ok());
2682        }
2683    }
2684
2685    /// Translator routing `.aa` files to one fake server, tracking at most one
2686    /// document so a second open must evict or be refused.
2687    fn single_document_translator(dir: &TempDir) -> (Translator, crate::test_lsp::FakeServer) {
2688        use crate::bridge::state::ResourceLimits;
2689
2690        let mut extensions = HashMap::new();
2691        extensions.insert(
2692            FileExtension::from_static("aa"),
2693            LanguageId::from_static("lang_a"),
2694        );
2695
2696        let mut translator = Translator::new()
2697            .with_extensions(extensions)
2698            .with_router(ToolRouter::catch_all([(
2699                ServerId::from_static("lang_a"),
2700                LanguageId::from_static("lang_a"),
2701            )]))
2702            .with_resource_limits(ResourceLimits {
2703                max_documents: DocumentLimit::new(1),
2704                max_file_size: SizeLimit::UNLIMITED,
2705            });
2706        translator
2707            .set_workspace_roots(WorkspaceRoots::from_paths(&[dir.path().to_path_buf()]).unwrap());
2708
2709        let (client, server) = fake_lsp_client();
2710        translator.register_client(ServerId::from_static("lang_a"), client);
2711        (translator, server)
2712    }
2713
2714    /// #515: a close that fails for one server must not stop the other
2715    /// server's close, and the failed debt is dropped, not re-owed.
2716    #[tokio::test]
2717    async fn test_flush_pending_closes_drops_failed_servers_debt() {
2718        let dir = TempDir::new().unwrap();
2719        let (translator, mut server_a) = single_document_translator(&dir);
2720        let (client_d, _server_d) = fake_lsp_client();
2721
2722        let tracker = &translator.document_tracker;
2723        let path = dir.path().join("p.aa");
2724        std::fs::write(&path, "p").unwrap();
2725        let other = dir.path().join("q.aa");
2726        std::fs::write(&other, "q").unwrap();
2727        let client_a = lock_std(&translator.servers)
2728            .client(&ServerId::from_static("lang_a"))
2729            .unwrap();
2730        tracker
2731            .ensure_open_for_test(&path, &ServerId::from_static("lang_a"), &client_a)
2732            .await
2733            .unwrap();
2734        tracker
2735            .ensure_open_for_test(&path, &ServerId::from_static("lang_d"), &client_d)
2736            .await
2737            .unwrap();
2738        tracker
2739            .ensure_open_for_test(&other, &ServerId::from_static("lang_a"), &client_a)
2740            .await
2741            .unwrap();
2742
2743        translator.register_client(ServerId::from_static("lang_d"), client_d.clone());
2744        client_d.shutdown().await.unwrap();
2745
2746        translator.flush_pending_closes().await;
2747
2748        let mut wire = tokio::io::BufReader::new(&mut server_a.write_stdout);
2749        for method in [
2750            "textDocument/didOpen",
2751            "textDocument/didOpen",
2752            "textDocument/didClose",
2753        ] {
2754            assert_eq!(read_framed_message(&mut wire).await["method"], method);
2755        }
2756        assert!(
2757            tracker.pending_close_paths().is_empty(),
2758            "a failed close is dropped, not re-owed"
2759        );
2760    }
2761
2762    /// #687 FR-027: a flush abandoned while it waits on one server leaves
2763    /// every close it did not deliver owed, and none it delivered.
2764    #[tokio::test(start_paused = true)]
2765    async fn test_flush_pending_closes_dropped_midway_keeps_undelivered_closes_owed() {
2766        let dir = TempDir::new().unwrap();
2767        let (translator, mut server_a) = single_document_translator(&dir);
2768        let (client_d, _probe_d) =
2769            LspClient::with_command_channel(crate::config::LspServerConfig::rust_analyzer(), 2);
2770        let (a, d) = (
2771            ServerId::from_static("lang_a"),
2772            ServerId::from_static("lang_d"),
2773        );
2774
2775        let tracker = &translator.document_tracker;
2776        let path = dir.path().join("p.aa");
2777        std::fs::write(&path, "p").unwrap();
2778        let other = dir.path().join("q.aa");
2779        std::fs::write(&other, "q").unwrap();
2780        let client_a = lock_std(&translator.servers).client(&a).unwrap();
2781        tracker
2782            .ensure_open_for_test(&path, &a, &client_a)
2783            .await
2784            .unwrap();
2785        tracker
2786            .ensure_open_for_test(&path, &d, &client_d)
2787            .await
2788            .unwrap();
2789        tracker
2790            .ensure_open_for_test(&other, &a, &client_a)
2791            .await
2792            .unwrap();
2793        translator.register_client(d.clone(), client_d.clone());
2794        client_d
2795            .notify("test/fill", serde_json::json!({}))
2796            .await
2797            .unwrap();
2798
2799        {
2800            let flush = translator.flush_pending_closes();
2801            let mut flush = std::pin::pin!(flush);
2802            assert!(futures::poll!(flush.as_mut()).is_pending());
2803        }
2804
2805        let mut wire = tokio::io::BufReader::with_capacity(1, &mut server_a.write_stdout);
2806        for method in ["textDocument/didOpen", "textDocument/didOpen"] {
2807            assert_eq!(read_framed_message(&mut wire).await["method"], method);
2808        }
2809        let a_close_delivered =
2810            tokio::time::timeout(Duration::from_millis(100), read_framed_message(&mut wire))
2811                .await
2812                .is_ok();
2813        let owed: Vec<ServerId> = tracker
2814            .try_claim_pending_close(&path)
2815            .unwrap()
2816            .servers()
2817            .cloned()
2818            .collect();
2819        assert!(
2820            owed.contains(&d),
2821            "the close nobody delivered must stay owed"
2822        );
2823        assert_eq!(
2824            owed.contains(&a),
2825            !a_close_delivered,
2826            "a close is owed exactly when it was not delivered: {owed:?}"
2827        );
2828    }
2829
2830    /// #495: once `DocumentTracker::open`'s LRU eviction reclaims a document
2831    /// to make room under `max_documents`, `prepare_document` must notify
2832    /// that document's server with `textDocument/didClose` -- `DocumentTracker`
2833    /// itself has no `LspClient` access to do this, so it's `Translator`'s
2834    /// job (`flush_pending_closes`) once `ensure_open` returns.
2835    #[tokio::test]
2836    async fn test_prepare_document_sends_didclose_for_evicted_document() {
2837        let dir = TempDir::new().unwrap();
2838        let (translator, mut server) = single_document_translator(&dir);
2839
2840        let path_a = dir.path().join("a.aa");
2841        fs::write(&path_a, "content a").unwrap();
2842        let path_b = dir.path().join("b.aa");
2843        fs::write(&path_b, "content b").unwrap();
2844
2845        drop(
2846            translator
2847                .prepare_document(
2848                    &client_path(path_a.to_string_lossy().into_owned()),
2849                    ToolKind::Hover,
2850                )
2851                .await
2852                .unwrap(),
2853        );
2854
2855        let mut wire = BufReader::new(&mut server.write_stdout);
2856        let opened_a = read_framed_message(&mut wire).await;
2857        assert_eq!(opened_a["method"], "textDocument/didOpen");
2858
2859        drop(
2860            translator
2861                .prepare_document(
2862                    &client_path(path_b.to_string_lossy().into_owned()),
2863                    ToolKind::Hover,
2864                )
2865                .await
2866                .unwrap(),
2867        );
2868
2869        let opened_b = read_framed_message(&mut wire).await;
2870        assert_eq!(opened_b["method"], "textDocument/didOpen");
2871        let closed_a = read_framed_message(&mut wire).await;
2872        assert_eq!(
2873            closed_a["method"], "textDocument/didClose",
2874            "evicting `a` to make room for `b` under max_documents: 1 must notify its server"
2875        );
2876        assert_eq!(
2877            closed_a["params"]["textDocument"]["uri"], opened_a["params"]["textDocument"]["uri"],
2878            "the didClose must name the evicted document, not the newly opened one"
2879        );
2880    }
2881
2882    /// #503: a handler still holding its `PreparedDocument` (i.e. mid LSP
2883    /// round-trip) keeps that document out of eviction, so a concurrent
2884    /// `prepare_document` for another path at `max_documents: 1` fails with
2885    /// `DocumentLimitExceeded` instead of evicting it; once the first
2886    /// `PreparedDocument` drops, the second path opens and evicts the first.
2887    #[tokio::test]
2888    async fn test_prepared_document_blocks_eviction_until_dropped() {
2889        let dir = TempDir::new().unwrap();
2890        let (translator, _server) = single_document_translator(&dir);
2891
2892        let path_a = dir.path().join("a.aa");
2893        fs::write(&path_a, "content a").unwrap();
2894        let path_b = dir.path().join("b.aa");
2895        fs::write(&path_b, "content b").unwrap();
2896
2897        let doc_a = translator
2898            .prepare_document(
2899                &client_path(path_a.to_string_lossy().into_owned()),
2900                ToolKind::Hover,
2901            )
2902            .await
2903            .unwrap();
2904
2905        let err = translator
2906            .prepare_document(
2907                &client_path(path_b.to_string_lossy().into_owned()),
2908                ToolKind::Hover,
2909            )
2910            .await
2911            .unwrap_err();
2912        assert_matches!(err, Error::DocumentLimitExceeded { .. });
2913
2914        drop(doc_a);
2915        drop(
2916            translator
2917                .prepare_document(
2918                    &client_path(path_b.to_string_lossy().into_owned()),
2919                    ToolKind::Hover,
2920                )
2921                .await
2922                .unwrap(),
2923        );
2924    }
2925
2926    /// #503: two handlers holding the same path concurrently each own a
2927    /// guard; the path stays protected until the *second* one drops.
2928    #[tokio::test]
2929    async fn test_two_prepared_documents_on_same_path_are_refcounted() {
2930        let dir = TempDir::new().unwrap();
2931        let (translator, _server) = single_document_translator(&dir);
2932
2933        let path_a = dir.path().join("a.aa");
2934        fs::write(&path_a, "content a").unwrap();
2935        let path_b = dir.path().join("b.aa");
2936        fs::write(&path_b, "content b").unwrap();
2937        let canonical_a = dunce::canonicalize(&path_a).unwrap();
2938
2939        let first = translator
2940            .prepare_document(
2941                &client_path(path_a.to_string_lossy().into_owned()),
2942                ToolKind::Hover,
2943            )
2944            .await
2945            .unwrap();
2946        let second = translator
2947            .prepare_document(
2948                &client_path(path_a.to_string_lossy().into_owned()),
2949                ToolKind::Hover,
2950            )
2951            .await
2952            .unwrap();
2953        assert_eq!(translator.document_tracker.in_flight_count(&canonical_a), 2);
2954
2955        drop(first);
2956        let err = translator
2957            .prepare_document(
2958                &client_path(path_b.to_string_lossy().into_owned()),
2959                ToolKind::Hover,
2960            )
2961            .await
2962            .unwrap_err();
2963        assert_matches!(err, Error::DocumentLimitExceeded { .. });
2964
2965        drop(second);
2966        assert_eq!(translator.document_tracker.in_flight_count(&canonical_a), 0);
2967        drop(
2968            translator
2969                .prepare_document(
2970                    &client_path(path_b.to_string_lossy().into_owned()),
2971                    ToolKind::Hover,
2972                )
2973                .await
2974                .unwrap(),
2975        );
2976    }
2977
2978    /// #503: when `ensure_open` fails, `prepare_document` returns the error
2979    /// without leaving the path marked in flight.
2980    #[tokio::test]
2981    async fn test_prepare_document_releases_in_flight_guard_when_ensure_open_fails() {
2982        let dir = TempDir::new().unwrap();
2983        let mut extensions = HashMap::new();
2984        extensions.insert(
2985            FileExtension::from_static("bb"),
2986            LanguageId::from_static("lang_b"),
2987        );
2988
2989        let mut translator =
2990            Translator::new()
2991                .with_extensions(extensions)
2992                .with_router(ToolRouter::catch_all([(
2993                    ServerId::from_static("lang_b"),
2994                    LanguageId::from_static("lang_b"),
2995                )]));
2996        translator
2997            .set_workspace_roots(WorkspaceRoots::from_paths(&[dir.path().to_path_buf()]).unwrap());
2998
2999        let (client, _server) = fake_lsp_client();
3000        translator.register_client(ServerId::from_static("lang_b"), client.clone());
3001
3002        let path_b = dir.path().join("b.bb");
3003        fs::write(&path_b, "content b").unwrap();
3004        let canonical_b = dunce::canonicalize(&path_b).unwrap();
3005
3006        client.shutdown().await.unwrap();
3007        let err = translator
3008            .prepare_document(
3009                &client_path(path_b.to_string_lossy().into_owned()),
3010                ToolKind::Hover,
3011            )
3012            .await
3013            .unwrap_err();
3014
3015        assert_matches!(err, Error::ServerTerminated);
3016        assert_eq!(translator.document_tracker.in_flight_count(&canonical_b), 0);
3017    }
3018
3019    /// #495 S5: even when `ensure_open` itself fails for the document being
3020    /// opened (here: its own `didOpen` notify fails), a `didClose` already
3021    /// queued for a *different* document evicted earlier in that same call
3022    /// must still be sent -- `prepare_document` must not lose it by
3023    /// returning early via `?` before flushing pending closes. Uses two
3024    /// separate servers (`lang_a` stays healthy, `lang_b`'s connection is
3025    /// broken) so the evicted document's own `didClose` delivery can be
3026    /// observed independently of the failure that aborts this call.
3027    #[tokio::test]
3028    async fn test_prepare_document_still_sends_didclose_when_ensure_open_itself_fails() {
3029        use crate::bridge::state::ResourceLimits;
3030
3031        let dir = TempDir::new().unwrap();
3032        let mut extensions = HashMap::new();
3033        extensions.insert(
3034            FileExtension::from_static("aa"),
3035            LanguageId::from_static("lang_a"),
3036        );
3037        extensions.insert(
3038            FileExtension::from_static("bb"),
3039            LanguageId::from_static("lang_b"),
3040        );
3041
3042        let mut translator = Translator::new()
3043            .with_extensions(extensions)
3044            .with_router(ToolRouter::catch_all([
3045                (
3046                    ServerId::from_static("lang_a"),
3047                    LanguageId::from_static("lang_a"),
3048                ),
3049                (
3050                    ServerId::from_static("lang_b"),
3051                    LanguageId::from_static("lang_b"),
3052                ),
3053            ]))
3054            .with_resource_limits(ResourceLimits {
3055                max_documents: DocumentLimit::new(1),
3056                max_file_size: SizeLimit::UNLIMITED,
3057            });
3058        translator
3059            .set_workspace_roots(WorkspaceRoots::from_paths(&[dir.path().to_path_buf()]).unwrap());
3060
3061        let (client_a, mut server_a) = fake_lsp_client();
3062        translator.register_client(ServerId::from_static("lang_a"), client_a);
3063        let (client_b, _server_b) = fake_lsp_client();
3064        translator.register_client(ServerId::from_static("lang_b"), client_b.clone());
3065
3066        let path_a = dir.path().join("a.aa");
3067        fs::write(&path_a, "content a").unwrap();
3068        let path_b = dir.path().join("b.bb");
3069        fs::write(&path_b, "content b").unwrap();
3070
3071        drop(
3072            translator
3073                .prepare_document(
3074                    &client_path(path_a.to_string_lossy().into_owned()),
3075                    ToolKind::Hover,
3076                )
3077                .await
3078                .unwrap(),
3079        );
3080
3081        let mut wire_a = BufReader::new(&mut server_a.write_stdout);
3082        let opened_a = read_framed_message(&mut wire_a).await;
3083        assert_eq!(opened_a["method"], "textDocument/didOpen");
3084
3085        // Break only `lang_b`'s connection -- see
3086        // `test_first_open_self_heals_when_did_open_notify_fails` (state.rs)
3087        // for why shutting down a clone deterministically fails the next
3088        // `notify()` on any other clone of the same client.
3089        client_b.shutdown().await.unwrap();
3090
3091        let err = translator
3092            .prepare_document(
3093                &client_path(path_b.to_string_lossy().into_owned()),
3094                ToolKind::Hover,
3095            )
3096            .await
3097            .unwrap_err();
3098        assert_matches!(err, Error::ServerTerminated);
3099
3100        // `a` was evicted (LRU, to make room for `b`) before `b`'s own
3101        // notify failed, and its didClose must still have gone out on
3102        // `lang_a`'s still-healthy connection.
3103        let closed_a = read_framed_message(&mut wire_a).await;
3104        assert_eq!(closed_a["method"], "textDocument/didClose");
3105        assert_eq!(
3106            closed_a["params"]["textDocument"]["uri"],
3107            opened_a["params"]["textDocument"]["uri"]
3108        );
3109    }
3110
3111    /// #174 §12's own headline dispatch scenario: "pyright/pylsp fixture --
3112    /// hover -> pyright, diagnostics -> pylsp, rename (unclaimed) ->
3113    /// `NoServerForTool`", exercised through `Translator`'s public handlers
3114    /// end to end rather than through `ToolRouter`'s unit tests alone.
3115    #[tokio::test]
3116    async fn test_dispatch_routes_hover_and_diagnostics_to_different_servers() {
3117        let dir = TempDir::new().unwrap();
3118        let mut extensions = HashMap::new();
3119        extensions.insert(
3120            FileExtension::from_static("py"),
3121            LanguageId::from_static("python"),
3122        );
3123
3124        let pyright_id = ServerId::from_static("pyright");
3125        let pylsp_id = ServerId::from_static("pylsp");
3126        let pyright = LspServerConfig {
3127            language_id: LanguageId::from_static("python"),
3128            command: ServerCommand::from_static("pyright-langserver").into(),
3129            args: crate::config::ServerArgs::default(),
3130            env: crate::config::ServerEnv::default(),
3131            file_patterns: vec![],
3132            initialization_options: None,
3133            settings: None,
3134            timeout_seconds: TimeoutSecs::new(30).unwrap(),
3135            request_timeout_seconds: TimeoutSecs::new(30).unwrap(),
3136            heuristics: None,
3137            name: Some(ServerId::from_static("pyright")),
3138            handles: Some(ToolSet::single(ToolKind::Hover)),
3139            indexing: crate::bridge::IndexingPolicy::Auto,
3140        };
3141        let pylsp = LspServerConfig {
3142            command: ServerCommand::from_static("pylsp").into(),
3143            name: Some(ServerId::from_static("pylsp")),
3144            handles: Some(ToolSet::single(ToolKind::Diagnostics)),
3145            ..pyright.clone()
3146        };
3147        let configs = vec![pyright, pylsp];
3148        let router = ToolRouter::from_configs(&configs).unwrap();
3149
3150        let mut translator = Translator::new()
3151            .with_extensions(extensions)
3152            .with_router(router);
3153        translator
3154            .set_workspace_roots(WorkspaceRoots::from_paths(&[dir.path().to_path_buf()]).unwrap());
3155
3156        let (client_pyright, mut server_pyright) = fake_lsp_client();
3157        let (client_pylsp, mut server_pylsp) = fake_lsp_client();
3158        translator.register_client(pyright_id, client_pyright);
3159        translator.register_client(pylsp_id, client_pylsp);
3160
3161        let path = dir.path().join("main.py");
3162        fs::write(&path, "x = 1").unwrap();
3163        let file = client_path(&path);
3164
3165        let translator = Arc::new(translator);
3166
3167        // rename is claimed by neither server -> NoServerForTool, checked
3168        // first so it can't be masked by either server's wire state.
3169        let rename_result = translator
3170            .handle_rename(
3171                file.clone(),
3172                pos(1, 1),
3173                crate::bridge::NewName::new("renamed").unwrap(),
3174            )
3175            .await;
3176        assert_matches!(
3177            rename_result,
3178            Err(Error::NoServerForTool {
3179                tool: ToolKind::Rename,
3180                ..
3181            }),
3182            "expected NoServerForTool for rename, got {rename_result:?}"
3183        );
3184
3185        // hover must route to pyright: didOpen + hover request on its wire.
3186        let hover = {
3187            let translator = Arc::clone(&translator);
3188            let file = file.clone();
3189            tokio::spawn(async move { translator.handle_hover(file, pos(1, 1)).await })
3190        };
3191        let mut wire_pyright = BufReader::new(&mut server_pyright.write_stdout);
3192        let opened = read_framed_message(&mut wire_pyright).await;
3193        assert_eq!(opened["method"], "textDocument/didOpen");
3194        let hover_request = read_framed_message(&mut wire_pyright).await;
3195        assert_eq!(hover_request["method"], "textDocument/hover");
3196        write_response(
3197            &mut server_pyright.read_half_stdin,
3198            &hover_request["id"],
3199            JsonValue::Null,
3200        )
3201        .await;
3202        hover
3203            .await
3204            .unwrap()
3205            .expect("hover routed to pyright must succeed");
3206
3207        // diagnostics must route to pylsp, independently of pyright: its own
3208        // didOpen (a second server's first sync of the same path) followed
3209        // by the diagnostic request on pylsp's wire, never pyright's.
3210        let diagnostics = {
3211            let translator = Arc::clone(&translator);
3212            let notification_cache = Arc::new(Mutex::new(NotificationCache::new()));
3213            tokio::spawn(async move {
3214                translator
3215                    .handle_diagnostics(file, ResultContext::None, &notification_cache)
3216                    .await
3217            })
3218        };
3219        let mut wire_pylsp = BufReader::new(&mut server_pylsp.write_stdout);
3220        let opened = read_framed_message(&mut wire_pylsp).await;
3221        assert_eq!(opened["method"], "textDocument/didOpen");
3222        let diag_request = read_framed_message(&mut wire_pylsp).await;
3223        assert_eq!(diag_request["method"], "textDocument/diagnostic");
3224        // Routing is proven by the request landing on pylsp's wire; abort
3225        // rather than crafting a well-formed DocumentDiagnosticReportResult.
3226        diagnostics.abort();
3227    }
3228
3229    /// No `LspServer` registered for `server_id` (only a raw `LspClient`, as
3230    /// most tests in this module do) -- capability is unknown, so the gate
3231    /// must not block the request.
3232    #[test]
3233    fn test_require_capability_ok_when_server_not_registered() {
3234        let translator = Translator::new();
3235        let result =
3236            translator.require_capability(&ServerId::from_static("rust"), Capability::Rename);
3237        assert!(result.is_ok());
3238    }
3239
3240    #[tokio::test]
3241    async fn test_require_capability_ok_when_capability_present() {
3242        let translator = Translator::new();
3243        let server_id = ServerId::from_static("rust");
3244        let caps = lsp_types::ServerCapabilities {
3245            rename_provider: Some(lsp_types::RenameProvider::Bool(true)),
3246            ..Default::default()
3247        };
3248        translator.register_server(server_id.clone(), LspServer::new_for_test(caps));
3249
3250        let result = translator.require_capability(&server_id, Capability::Rename);
3251        assert!(result.is_ok());
3252    }
3253
3254    #[tokio::test]
3255    async fn test_require_capability_err_when_capability_absent() {
3256        let translator = Translator::new();
3257        let server_id = ServerId::from_static("rust");
3258        let caps = lsp_types::ServerCapabilities::default();
3259        translator.register_server(server_id.clone(), LspServer::new_for_test(caps));
3260
3261        let result = translator.require_capability(&server_id, Capability::Rename);
3262        assert_matches!(
3263            result,
3264            Err(Error::CapabilityNotSupported {
3265                capability: Capability::Rename,
3266                ..
3267            })
3268        );
3269    }
3270
3271    #[tokio::test]
3272    async fn test_handle_rename_blocked_when_capability_not_supported() {
3273        let dir = TempDir::new().unwrap();
3274        let server_id = ServerId::from_static("rust");
3275        let (translator, _server) = translator_with_capabilities(
3276            &dir,
3277            &server_id,
3278            lsp_types::ServerCapabilities::default(),
3279        );
3280
3281        let path = dir.path().join("main.rs");
3282        fs::write(&path, "fn main() {}").unwrap();
3283
3284        let result = translator
3285            .handle_rename(
3286                client_path(path.to_string_lossy().into_owned()),
3287                Position::at(1, 1),
3288                crate::bridge::NewName::new("renamed").unwrap(),
3289            )
3290            .await;
3291
3292        assert_matches!(
3293            result,
3294            Err(Error::CapabilityNotSupported {
3295                capability: Capability::Rename,
3296                ..
3297            })
3298        );
3299    }
3300
3301    #[tokio::test]
3302    async fn test_handle_code_actions_blocked_when_capability_not_supported() {
3303        let dir = TempDir::new().unwrap();
3304        let server_id = ServerId::from_static("rust");
3305        let (translator, _server) = translator_with_capabilities(
3306            &dir,
3307            &server_id,
3308            lsp_types::ServerCapabilities::default(),
3309        );
3310
3311        let path = dir.path().join("main.rs");
3312        fs::write(&path, "fn main() {}").unwrap();
3313
3314        let result = translator
3315            .handle_code_actions(
3316                client_path(path.to_string_lossy().into_owned()),
3317                bounded(Position::at(1, 1), Position::at(1, 5)),
3318                None,
3319            )
3320            .await;
3321
3322        assert_matches!(
3323            result,
3324            Err(Error::CapabilityNotSupported {
3325                capability: Capability::CodeActions,
3326                ..
3327            })
3328        );
3329    }
3330
3331    #[tokio::test]
3332    async fn test_handle_signature_help_blocked_when_capability_not_supported() {
3333        let dir = TempDir::new().unwrap();
3334        let server_id = ServerId::from_static("rust");
3335        let (translator, _server) = translator_with_capabilities(
3336            &dir,
3337            &server_id,
3338            lsp_types::ServerCapabilities::default(),
3339        );
3340
3341        let path = dir.path().join("main.rs");
3342        fs::write(&path, "fn main() {}").unwrap();
3343
3344        let result = translator
3345            .handle_signature_help(
3346                client_path(path.to_string_lossy().into_owned()),
3347                Position::at(1, 1),
3348            )
3349            .await;
3350
3351        assert_matches!(
3352            result,
3353            Err(Error::CapabilityNotSupported {
3354                capability: Capability::SignatureHelp,
3355                ..
3356            })
3357        );
3358    }
3359
3360    /// `handle_incoming_calls` resolves its server via `client_for_file`
3361    /// directly (not `prepare_document`), a separate code path from the other
3362    /// gated handlers -- exercise it explicitly.
3363    #[tokio::test]
3364    async fn test_handle_incoming_calls_blocked_when_capability_not_supported() {
3365        let dir = TempDir::new().unwrap();
3366        let server_id = ServerId::from_static("rust");
3367        let (translator, _server) = translator_with_capabilities(
3368            &dir,
3369            &server_id,
3370            lsp_types::ServerCapabilities::default(),
3371        );
3372
3373        let path = dir.path().join("main.rs");
3374        fs::write(&path, "fn main() {}").unwrap();
3375        let uri = Url::from_file_path(&path).unwrap().to_string();
3376
3377        let item = serde_json::json!({
3378            "name": "test_function",
3379            "kind": 12,
3380            "uri": uri,
3381            "range": {
3382                "start": {"line": 1, "character": 1},
3383                "end": {"line": 1, "character": 10}
3384            },
3385            "selectionRange": {
3386                "start": {"line": 1, "character": 1},
3387                "end": {"line": 1, "character": 10}
3388            }
3389        });
3390
3391        let result = translator
3392            .handle_incoming_calls(checked_hierarchy_item(item))
3393            .await;
3394
3395        assert_matches!(
3396            result,
3397            Err(Error::CapabilityNotSupported {
3398                capability: Capability::CallHierarchy,
3399                ..
3400            })
3401        );
3402    }
3403
3404    #[tokio::test]
3405    async fn test_handle_outgoing_calls_blocked_when_capability_not_supported() {
3406        let dir = TempDir::new().unwrap();
3407        let server_id = ServerId::from_static("rust");
3408        let (translator, _server) = translator_with_capabilities(
3409            &dir,
3410            &server_id,
3411            lsp_types::ServerCapabilities::default(),
3412        );
3413
3414        let path = dir.path().join("main.rs");
3415        fs::write(&path, "fn main() {}").unwrap();
3416        let uri = Url::from_file_path(&path).unwrap().to_string();
3417
3418        let item = serde_json::json!({
3419            "name": "test_function",
3420            "kind": 12,
3421            "uri": uri,
3422            "range": {
3423                "start": {"line": 1, "character": 1},
3424                "end": {"line": 1, "character": 10}
3425            },
3426            "selectionRange": {
3427                "start": {"line": 1, "character": 1},
3428                "end": {"line": 1, "character": 10}
3429            }
3430        });
3431
3432        let result = translator
3433            .handle_outgoing_calls(checked_hierarchy_item(item))
3434            .await;
3435
3436        assert_matches!(
3437            result,
3438            Err(Error::CapabilityNotSupported {
3439                capability: Capability::CallHierarchy,
3440                ..
3441            })
3442        );
3443    }
3444
3445    #[tokio::test]
3446    async fn test_handle_format_document_blocked_when_capability_not_supported() {
3447        let dir = TempDir::new().unwrap();
3448        let server_id = ServerId::from_static("rust");
3449        let (translator, _server) = translator_with_capabilities(
3450            &dir,
3451            &server_id,
3452            lsp_types::ServerCapabilities::default(),
3453        );
3454
3455        let path = dir.path().join("main.rs");
3456        fs::write(&path, "fn main() {}").unwrap();
3457
3458        let result = translator
3459            .handle_format_document(
3460                client_path(path.to_string_lossy().into_owned()),
3461                crate::bridge::TabSize::default(),
3462                true,
3463            )
3464            .await;
3465
3466        assert_matches!(
3467            result,
3468            Err(Error::CapabilityNotSupported {
3469                capability: Capability::FormatDocument,
3470                ..
3471            })
3472        );
3473    }
3474
3475    #[tokio::test]
3476    async fn test_handle_call_hierarchy_prepare_blocked_when_capability_not_supported() {
3477        let dir = TempDir::new().unwrap();
3478        let server_id = ServerId::from_static("rust");
3479        let (translator, _server) = translator_with_capabilities(
3480            &dir,
3481            &server_id,
3482            lsp_types::ServerCapabilities::default(),
3483        );
3484
3485        let path = dir.path().join("main.rs");
3486        fs::write(&path, "fn main() {}").unwrap();
3487
3488        let result = translator
3489            .handle_call_hierarchy_prepare(
3490                client_path(path.to_string_lossy().into_owned()),
3491                Position::at(1, 1),
3492            )
3493            .await;
3494
3495        assert_matches!(
3496            result,
3497            Err(Error::CapabilityNotSupported {
3498                capability: Capability::CallHierarchy,
3499                ..
3500            })
3501        );
3502    }
3503
3504    #[tokio::test]
3505    async fn test_handle_inlay_hints_blocked_when_capability_not_supported() {
3506        let dir = TempDir::new().unwrap();
3507        let server_id = ServerId::from_static("rust");
3508        let (translator, _server) = translator_with_capabilities(
3509            &dir,
3510            &server_id,
3511            lsp_types::ServerCapabilities::default(),
3512        );
3513
3514        let path = dir.path().join("main.rs");
3515        fs::write(&path, "fn main() {}").unwrap();
3516
3517        let result = translator
3518            .handle_inlay_hints(
3519                client_path(path.to_string_lossy().into_owned()),
3520                span(Position::at(1, 1), Position::at(10, 1)),
3521            )
3522            .await;
3523
3524        assert_matches!(
3525            result,
3526            Err(Error::CapabilityNotSupported {
3527                capability: Capability::InlayHints,
3528                ..
3529            })
3530        );
3531    }
3532
3533    #[tokio::test]
3534    async fn test_handle_hover_blocked_when_capability_not_supported() {
3535        let dir = TempDir::new().unwrap();
3536        let server_id = ServerId::from_static("rust");
3537        let (translator, _server) = translator_with_capabilities(
3538            &dir,
3539            &server_id,
3540            lsp_types::ServerCapabilities::default(),
3541        );
3542
3543        let path = dir.path().join("main.rs");
3544        fs::write(&path, "fn main() {}").unwrap();
3545
3546        let result = translator
3547            .handle_hover(
3548                client_path(path.to_string_lossy().into_owned()),
3549                Position::at(1, 1),
3550            )
3551            .await;
3552
3553        assert_matches!(
3554            result,
3555            Err(Error::CapabilityNotSupported {
3556                capability: Capability::Hover,
3557                ..
3558            })
3559        );
3560    }
3561
3562    #[tokio::test]
3563    async fn test_handle_definition_blocked_when_capability_not_supported() {
3564        let dir = TempDir::new().unwrap();
3565        let server_id = ServerId::from_static("rust");
3566        let (translator, _server) = translator_with_capabilities(
3567            &dir,
3568            &server_id,
3569            lsp_types::ServerCapabilities::default(),
3570        );
3571
3572        let path = dir.path().join("main.rs");
3573        fs::write(&path, "fn main() {}").unwrap();
3574
3575        let result = translator
3576            .handle_definition(
3577                client_path(path.to_string_lossy().into_owned()),
3578                Position::at(1, 1),
3579                ResultContext::None,
3580            )
3581            .await;
3582
3583        assert_matches!(
3584            result,
3585            Err(Error::CapabilityNotSupported {
3586                capability: Capability::Definition,
3587                ..
3588            })
3589        );
3590    }
3591
3592    #[tokio::test]
3593    async fn test_handle_references_blocked_when_capability_not_supported() {
3594        let dir = TempDir::new().unwrap();
3595        let server_id = ServerId::from_static("rust");
3596        let (translator, _server) = translator_with_capabilities(
3597            &dir,
3598            &server_id,
3599            lsp_types::ServerCapabilities::default(),
3600        );
3601
3602        let path = dir.path().join("main.rs");
3603        fs::write(&path, "fn main() {}").unwrap();
3604
3605        let result = translator
3606            .handle_references(
3607                client_path(path.to_string_lossy().into_owned()),
3608                Position::at(1, 1),
3609                false,
3610                ResultContext::None,
3611            )
3612            .await;
3613
3614        assert_matches!(
3615            result,
3616            Err(Error::CapabilityNotSupported {
3617                capability: Capability::References,
3618                ..
3619            })
3620        );
3621    }
3622
3623    #[tokio::test]
3624    async fn test_handle_completions_blocked_when_capability_not_supported() {
3625        let dir = TempDir::new().unwrap();
3626        let server_id = ServerId::from_static("rust");
3627        let (translator, _server) = translator_with_capabilities(
3628            &dir,
3629            &server_id,
3630            lsp_types::ServerCapabilities::default(),
3631        );
3632
3633        let path = dir.path().join("main.rs");
3634        fs::write(&path, "fn main() {}").unwrap();
3635
3636        let result = translator
3637            .handle_completions(
3638                client_path(path.to_string_lossy().into_owned()),
3639                Position::at(1, 1),
3640                None,
3641            )
3642            .await;
3643
3644        assert_matches!(
3645            result,
3646            Err(Error::CapabilityNotSupported {
3647                capability: Capability::Completions,
3648                ..
3649            })
3650        );
3651    }
3652
3653    #[tokio::test]
3654    async fn test_handle_document_symbols_blocked_when_capability_not_supported() {
3655        let dir = TempDir::new().unwrap();
3656        let server_id = ServerId::from_static("rust");
3657        let (translator, _server) = translator_with_capabilities(
3658            &dir,
3659            &server_id,
3660            lsp_types::ServerCapabilities::default(),
3661        );
3662
3663        let path = dir.path().join("main.rs");
3664        fs::write(&path, "fn main() {}").unwrap();
3665
3666        let result = translator
3667            .handle_document_symbols(client_path(path.to_string_lossy().into_owned()))
3668            .await;
3669
3670        assert_matches!(
3671            result,
3672            Err(Error::CapabilityNotSupported {
3673                capability: Capability::DocumentSymbols,
3674                ..
3675            })
3676        );
3677    }
3678
3679    #[tokio::test]
3680    async fn test_handle_workspace_symbol_blocked_when_capability_not_supported() {
3681        let dir = TempDir::new().unwrap();
3682        let server_id = ServerId::from_static("rust");
3683        let (translator, _server) = translator_with_capabilities(
3684            &dir,
3685            &server_id,
3686            lsp_types::ServerCapabilities::default(),
3687        );
3688
3689        let result = translator
3690            .handle_workspace_symbol("main".to_string(), None, 100)
3691            .await;
3692
3693        assert_matches!(
3694            result,
3695            Err(Error::CapabilityNotSupported {
3696                capability: Capability::WorkspaceSymbols,
3697                ..
3698            })
3699        );
3700    }
3701
3702    #[tokio::test]
3703    async fn test_handle_implementation_blocked_when_capability_not_supported() {
3704        let dir = TempDir::new().unwrap();
3705        let server_id = ServerId::from_static("rust");
3706        let (translator, _server) = translator_with_capabilities(
3707            &dir,
3708            &server_id,
3709            lsp_types::ServerCapabilities::default(),
3710        );
3711
3712        let path = dir.path().join("main.rs");
3713        fs::write(&path, "fn main() {}").unwrap();
3714
3715        let result = translator
3716            .handle_implementation(
3717                client_path(path.to_string_lossy().into_owned()),
3718                Position::at(1, 1),
3719                ResultContext::None,
3720            )
3721            .await;
3722
3723        assert_matches!(
3724            result,
3725            Err(Error::CapabilityNotSupported {
3726                capability: Capability::Implementation,
3727                ..
3728            })
3729        );
3730    }
3731
3732    #[tokio::test]
3733    async fn test_handle_type_definition_blocked_when_capability_not_supported() {
3734        let dir = TempDir::new().unwrap();
3735        let server_id = ServerId::from_static("rust");
3736        let (translator, _server) = translator_with_capabilities(
3737            &dir,
3738            &server_id,
3739            lsp_types::ServerCapabilities::default(),
3740        );
3741
3742        let path = dir.path().join("main.rs");
3743        fs::write(&path, "fn main() {}").unwrap();
3744
3745        let result = translator
3746            .handle_type_definition(
3747                client_path(path.to_string_lossy().into_owned()),
3748                Position::at(1, 1),
3749                ResultContext::None,
3750            )
3751            .await;
3752
3753        assert_matches!(
3754            result,
3755            Err(Error::CapabilityNotSupported {
3756                capability: Capability::TypeDefinition,
3757                ..
3758            })
3759        );
3760    }
3761
3762    #[tokio::test]
3763    async fn test_handle_declaration_blocked_when_capability_not_supported() {
3764        let dir = TempDir::new().unwrap();
3765        let server_id = ServerId::from_static("rust");
3766        let (translator, _server) = translator_with_capabilities(
3767            &dir,
3768            &server_id,
3769            lsp_types::ServerCapabilities::default(),
3770        );
3771
3772        let path = dir.path().join("main.rs");
3773        fs::write(&path, "fn main() {}").unwrap();
3774
3775        let result = translator
3776            .handle_declaration(
3777                client_path(&path),
3778                Position::at(1, 1),
3779                crate::bridge::ResultContext::None,
3780            )
3781            .await;
3782
3783        assert_matches!(
3784            result,
3785            Err(Error::CapabilityNotSupported {
3786                capability: Capability::Declaration,
3787                ..
3788            })
3789        );
3790    }
3791
3792    /// Explicit `Some(RenameProvider::Bool(false))` -- as distinct from an absent
3793    /// (`None`) field -- must also be rejected: some servers advertise a
3794    /// provider field with an explicit `false` rather than omitting it.
3795    #[tokio::test]
3796    async fn test_require_capability_err_when_capability_explicitly_false() {
3797        let translator = Translator::new();
3798        let server_id = ServerId::from_static("rust");
3799        let caps = lsp_types::ServerCapabilities {
3800            rename_provider: Some(lsp_types::RenameProvider::Bool(false)),
3801            ..Default::default()
3802        };
3803        translator.register_server(server_id.clone(), LspServer::new_for_test(caps));
3804
3805        let result = translator.require_capability(&server_id, Capability::Rename);
3806        assert_matches!(
3807            result,
3808            Err(Error::CapabilityNotSupported {
3809                capability: Capability::Rename,
3810                ..
3811            })
3812        );
3813    }
3814
3815    /// Positive path: when the routed server *does* advertise the gated
3816    /// capability, the gate must let the request proceed into dispatch rather
3817    /// than short-circuiting with `CapabilityNotSupported`. Drives the fake
3818    /// wire to answer the request so the call completes quickly instead of
3819    /// idling out its internal 30s request timeout.
3820    #[tokio::test]
3821    async fn test_handle_rename_proceeds_when_capability_supported() {
3822        let dir = TempDir::new().unwrap();
3823        let server_id = ServerId::from_static("rust");
3824        let caps = lsp_types::ServerCapabilities {
3825            rename_provider: Some(lsp_types::RenameProvider::Bool(true)),
3826            ..Default::default()
3827        };
3828        let (translator, mut server) = translator_with_capabilities(&dir, &server_id, caps);
3829
3830        let path = dir.path().join("main.rs");
3831        fs::write(&path, "fn main() {}").unwrap();
3832        let path_str = path.to_string_lossy().to_string();
3833
3834        let translator = Arc::new(translator);
3835        let handle = {
3836            let translator = Arc::clone(&translator);
3837            tokio::spawn(async move {
3838                translator
3839                    .handle_rename(
3840                        client_path(path_str),
3841                        Position::at(1, 1),
3842                        crate::bridge::NewName::new("renamed").unwrap(),
3843                    )
3844                    .await
3845            })
3846        };
3847
3848        let mut wire = BufReader::new(&mut server.write_stdout);
3849        let opened = read_framed_message(&mut wire).await;
3850        assert_eq!(opened["method"], "textDocument/didOpen");
3851        let rename_request = read_framed_message(&mut wire).await;
3852        assert_eq!(rename_request["method"], "textDocument/rename");
3853        write_response(
3854            &mut server.read_half_stdin,
3855            &rename_request["id"],
3856            JsonValue::Null,
3857        )
3858        .await;
3859
3860        let result = timeout(Duration::from_secs(2), handle)
3861            .await
3862            .expect("handler call should not hang")
3863            .unwrap();
3864
3865        assert!(
3866            !matches!(result, Err(Error::CapabilityNotSupported { .. })),
3867            "capability is supported, gate must not block dispatch, got {result:?}"
3868        );
3869        assert!(
3870            result.is_ok(),
3871            "fake server answered, expected Ok: {result:?}"
3872        );
3873    }
3874
3875    /// `forms` are the provider forms that advertise the capability.
3876    fn assert_gates_on_provider_forms<P>(
3877        supported: impl Fn(Option<P>) -> bool,
3878        forms: impl IntoIterator<Item = P>,
3879        disabled: P,
3880    ) {
3881        for form in forms {
3882            assert!(supported(Some(form)));
3883        }
3884        assert!(!supported(Some(disabled)));
3885        assert!(!supported(None));
3886    }
3887
3888    #[test]
3889    fn selection_range_capability_gates_on_every_provider_form() {
3890        use lsp_types::SelectionRangeProvider as Provider;
3891
3892        assert_gates_on_provider_forms(
3893            |provider| {
3894                Capability::SelectionRange.is_supported(&lsp_types::ServerCapabilities {
3895                    selection_range_provider: provider,
3896                    ..Default::default()
3897                })
3898            },
3899            [
3900                Provider::Bool(true),
3901                Provider::SelectionRangeOptions(lsp_types::SelectionRangeOptions::default()),
3902                Provider::SelectionRangeRegistrationOptions(
3903                    lsp_types::SelectionRangeRegistrationOptions::default(),
3904                ),
3905            ],
3906            Provider::Bool(false),
3907        );
3908    }
3909
3910    #[test]
3911    fn folding_range_capability_gates_on_every_provider_form() {
3912        use lsp_types::FoldingRangeProvider as Provider;
3913
3914        assert_gates_on_provider_forms(
3915            |provider| {
3916                Capability::FoldingRange.is_supported(&lsp_types::ServerCapabilities {
3917                    folding_range_provider: provider,
3918                    ..Default::default()
3919                })
3920            },
3921            [
3922                Provider::Bool(true),
3923                Provider::FoldingRangeOptions(lsp_types::FoldingRangeOptions::default()),
3924                Provider::FoldingRangeRegistrationOptions(
3925                    lsp_types::FoldingRangeRegistrationOptions::default(),
3926                ),
3927            ],
3928            Provider::Bool(false),
3929        );
3930    }
3931}