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