Skip to main content

mcpls_core/bridge/translator/
navigation.rs

1//! Hover, go-to-definition/implementation/type-definition, and references
2//! handlers.
3
4use std::time::Duration;
5
6use lsp_types::{
7    HoverParams as LspHoverParams, PartialResultParams, ReferenceContext, ReferenceParams,
8    TextDocumentIdentifier, TextDocumentPositionParams, WorkDoneProgressParams,
9};
10use tokio::time::Instant;
11
12use super::Translator;
13use super::dto::{
14    DefinitionResult, HoverResult, Location, LocationsResult, Position, ReferencesResult,
15};
16use super::encoding_ctx::EncodingCtx;
17use super::routing::{Capability, IndexingGate};
18use crate::bridge::IndexingState;
19use crate::bridge::indexing::{
20    DEFAULT_INDEXING_READY_TIMEOUT_SECS, INDEXING_STALENESS_BOUND, PROGRESS_LATCH_IDLE,
21    PROGRESS_SETTLE,
22};
23use crate::config::{ServerId, ToolKind};
24use crate::error::{Error, Result};
25
26/// Default maximum time [`Translator::wait_for_indexing_ready`] waits for a
27/// routed LSP server to report it has finished its initial workspace load,
28/// once a readiness signal has shown indexing is actually in progress.
29/// Matches the timeout already used throughout the rust-analyzer integration
30/// test suite's own (test-only) indexing-readiness helper.
31///
32/// This is only the built-in default (used by [`Translator::new`]) --
33/// overridable per `Translator` via [`Translator::with_indexing_ready_timeout`],
34/// wired from `workspace.indexing_ready_timeout_seconds` in `mcpls.toml`
35/// (#424). The compile-time invariants below are checked against this
36/// default; the same invariants are re-checked against a configured override
37/// at `ServerConfig::validate` time, since a runtime value can't be asserted
38/// at compile time.
39pub(super) const INDEXING_READY_TIMEOUT: Duration =
40    Duration::from_secs(DEFAULT_INDEXING_READY_TIMEOUT_SECS);
41
42/// Poll interval used while waiting out [`INDEXING_READY_TIMEOUT`]. A single
43/// mutex lock plus map lookup, not a network round trip, so a short
44/// interval adds no meaningful overhead relative to the LSP request that
45/// follows once the wait resolves.
46const INDEXING_POLL_INTERVAL: Duration = Duration::from_millis(100);
47
48/// `INDEXING_STALENESS_BOUND` must stay larger than `INDEXING_READY_TIMEOUT`,
49/// or the read-time staleness self-heal could fire within a single caller's
50/// own wait -- reintroducing the cross-caller self-heal race this bound
51/// exists to prevent.
52const _: () = assert!(
53    INDEXING_STALENESS_BOUND.as_nanos() > INDEXING_READY_TIMEOUT.as_nanos(),
54    "INDEXING_STALENESS_BOUND must be greater than INDEXING_READY_TIMEOUT"
55);
56
57/// `PROGRESS_SETTLE` (the read-path settle window) must stay shorter than
58/// `PROGRESS_LATCH_IDLE` (the write-path latch threshold) -- see
59/// `bridge::indexing::PROGRESS_LATCH_IDLE`'s doc for why collapsing the two
60/// into a single threshold reintroduces a real regression (N1).
61const _: () = assert!(
62    PROGRESS_SETTLE.as_nanos() < PROGRESS_LATCH_IDLE.as_nanos(),
63    "PROGRESS_SETTLE must be less than PROGRESS_LATCH_IDLE"
64);
65
66/// A settle window longer than the gate's own wait timeout would let
67/// `wait_for_indexing_ready` time out while the entry is merely mid-settle,
68/// not actually still loading.
69const _: () = assert!(
70    PROGRESS_SETTLE.as_nanos() < INDEXING_READY_TIMEOUT.as_nanos(),
71    "PROGRESS_SETTLE must be less than INDEXING_READY_TIMEOUT"
72);
73
74/// Flattens a `Definition` (`Location` or `Location[]`) into an owned `Vec`.
75fn definition_to_locations(definition: lsp_types::Definition) -> Vec<lsp_types::Location> {
76    match definition {
77        lsp_types::Definition::Location(loc) => vec![loc],
78        lsp_types::Definition::LocationList(locs) => locs,
79    }
80}
81
82/// Converts a `DefinitionLink` into a plain `Location` pointing at its target.
83fn definition_link_to_location(link: lsp_types::DefinitionLink) -> lsp_types::Location {
84    lsp_types::Location {
85        uri: link.target_uri,
86        range: link.target_selection_range,
87    }
88}
89
90/// Hard cap on the number of `Location`s/symbols a single call normalizes
91/// (`goto`, `references`, `workspace_symbol_search`). Without a limit, a
92/// response naming an unbounded number of locations turns one MCP tool call
93/// into an unbounded number of range conversions -- each one a potential
94/// disk read on a cache miss -- letting a hostile or misbehaving LSP server
95/// amplify one request into massive I/O (see #474). Applied before
96/// normalization, not after, so it bounds the work actually done rather
97/// than just the size of the returned list. Also used by
98/// `Translator::handle_workspace_symbol` to clamp its caller-supplied
99/// `limit`, which otherwise has no upper bound of its own.
100pub(super) const MAX_NORMALIZED_LOCATIONS: usize = 10_000;
101
102/// Converts raw LSP locations into MCP-facing `Location` values, normalizing
103/// each range into the caller's 1-based coordinate space.
104///
105/// Truncates to [`MAX_NORMALIZED_LOCATIONS`] first -- see its doc. Logs a
106/// single `warn!` when that truncation actually drops locations, so a
107/// response silently capped below what the LSP server reported is at least
108/// visible in logs (see #474).
109///
110/// Deliberately not filtered to workspace roots: unlike a write-bearing
111/// `WorkspaceEdit` (see `edits.rs`), a goto-X/references location is
112/// read-only, and legitimate results routinely point outside the workspace
113/// (e.g. the standard library or a crates.io dependency) -- dropping those
114/// would break ordinary navigation. Any subsequent attempt to open or read
115/// the path this location names still goes through the inbound
116/// `validate_path_against_roots` gate (`mcp/server.rs`), which fails closed,
117/// so the untrusted-URI concern is already covered downstream.
118async fn lsp_locations_to_mcp(
119    mut locs: Vec<lsp_types::Location>,
120    ctx: &EncodingCtx,
121) -> NormalizedLocations {
122    let truncated = locs.len() > MAX_NORMALIZED_LOCATIONS;
123    if truncated {
124        tracing::warn!(
125            reported = locs.len(),
126            cap = MAX_NORMALIZED_LOCATIONS,
127            "LSP response location count exceeds MAX_NORMALIZED_LOCATIONS; truncating"
128        );
129    }
130    locs.truncate(MAX_NORMALIZED_LOCATIONS);
131    let mut locations = Vec::with_capacity(locs.len());
132    for loc in locs {
133        locations.push(Location {
134            uri: loc.uri.to_string(),
135            range: ctx.normalize_range(&loc.uri, loc.range).await,
136            out_of_workspace: ctx.is_out_of_workspace(&loc.uri),
137        });
138    }
139    NormalizedLocations {
140        locations,
141        truncated,
142        positions_degraded: ctx.positions_degraded(),
143    }
144}
145
146/// [`lsp_locations_to_mcp`]'s result: the normalized locations plus whether
147/// [`MAX_NORMALIZED_LOCATIONS`] actually dropped any of the LSP server's
148/// reported locations -- surfaced to the MCP caller via each result DTO's
149/// `truncated` field, since `references`'/goto-X's tool descriptions
150/// otherwise imply a complete result (see #474) -- and whether any position
151/// among them could not be resolved for encoding conversion while
152/// normalizing (disk-read budget exhaustion, an unresolvable path, a line
153/// past EOF, or invalid UTF-8 -- see `ctx.positions_degraded()`), surfaced
154/// via each result DTO's `positions_degraded` field (#497).
155struct NormalizedLocations {
156    locations: Vec<Location>,
157    truncated: bool,
158    positions_degraded: bool,
159}
160
161/// The two response shapes shared by `textDocument/definition`,
162/// `textDocument/implementation`, and `textDocument/typeDefinition`: either a
163/// single `Definition` (`Location` or `Location[]`), or a `DefinitionLink[]`
164/// from clients that opted into `LinkSupport`.
165enum GotoKind {
166    /// A plain `Definition`, as returned to clients without `LinkSupport`.
167    Definition(lsp_types::Definition),
168    /// A `DefinitionLink[]`, as returned to clients with `LinkSupport`.
169    DefinitionLinkList(Vec<lsp_types::DefinitionLink>),
170}
171
172/// Implemented once per go-to-X response enum so [`goto_response_to_locations`]
173/// can normalize all three through one code path instead of three near-identical
174/// match arms.
175trait GotoResponse {
176    /// Reduce the response enum down to the two variants shared by every
177    /// go-to-X LSP response.
178    fn into_kind(self) -> GotoKind;
179}
180
181impl GotoResponse for lsp_types::DefinitionResponse {
182    fn into_kind(self) -> GotoKind {
183        match self {
184            Self::Definition(def) => GotoKind::Definition(def),
185            Self::DefinitionLinkList(links) => GotoKind::DefinitionLinkList(links),
186        }
187    }
188}
189
190impl GotoResponse for lsp_types::ImplementationResponse {
191    fn into_kind(self) -> GotoKind {
192        match self {
193            Self::Definition(def) => GotoKind::Definition(def),
194            Self::DefinitionLinkList(links) => GotoKind::DefinitionLinkList(links),
195        }
196    }
197}
198
199impl GotoResponse for lsp_types::TypeDefinitionResponse {
200    fn into_kind(self) -> GotoKind {
201        match self {
202            Self::Definition(def) => GotoKind::Definition(def),
203            Self::DefinitionLinkList(links) => GotoKind::DefinitionLinkList(links),
204        }
205    }
206}
207
208/// Normalize a go-to-X response (`textDocument/definition`,
209/// `textDocument/implementation`, or `textDocument/typeDefinition`) into a
210/// flat list of MCP `Location` values.
211async fn goto_response_to_locations<R: GotoResponse>(
212    response: Option<R>,
213    ctx: &EncodingCtx,
214) -> NormalizedLocations {
215    let lsp_locs = match response.map(GotoResponse::into_kind) {
216        Some(GotoKind::Definition(def)) => definition_to_locations(def),
217        Some(GotoKind::DefinitionLinkList(links)) => {
218            links.into_iter().map(definition_link_to_location).collect()
219        }
220        None => vec![],
221    };
222    lsp_locations_to_mcp(lsp_locs, ctx).await
223}
224
225/// Implemented once per go-to-X request params type so [`Translator::handle_goto`]
226/// can build request params generically instead of duplicating the
227/// `TextDocumentPositionParams` wiring per handler.
228trait GotoParams: Sized {
229    /// Build the request params from the resolved document position, filling
230    /// the remaining fields (work-done/partial-result progress) with defaults.
231    fn from_position(text_document_position_params: TextDocumentPositionParams) -> Self;
232}
233
234impl GotoParams for lsp_types::DefinitionParams {
235    fn from_position(text_document_position_params: TextDocumentPositionParams) -> Self {
236        Self {
237            text_document_position_params,
238            work_done_progress_params: WorkDoneProgressParams::default(),
239            partial_result_params: PartialResultParams::default(),
240        }
241    }
242}
243
244impl GotoParams for lsp_types::ImplementationParams {
245    fn from_position(text_document_position_params: TextDocumentPositionParams) -> Self {
246        Self {
247            text_document_position_params,
248            work_done_progress_params: WorkDoneProgressParams::default(),
249            partial_result_params: PartialResultParams::default(),
250        }
251    }
252}
253
254impl GotoParams for lsp_types::TypeDefinitionParams {
255    fn from_position(text_document_position_params: TextDocumentPositionParams) -> Self {
256        Self {
257            text_document_position_params,
258            work_done_progress_params: WorkDoneProgressParams::default(),
259            partial_result_params: PartialResultParams::default(),
260        }
261    }
262}
263
264/// Extracts hover contents as a plain string.
265///
266/// `MarkedString` is `#[deprecated]` in favor of `MarkupContent`, but LSP
267/// 3.17 servers may still send it inside `Hover.contents` -- dropping
268/// support would silently discard hover text from those servers, so this
269/// (and `marked_string_to_string`) carry a narrow, scoped allow rather than
270/// rewriting to `MarkupContent`-only.
271#[allow(deprecated)]
272fn extract_hover_contents(contents: lsp_types::Contents) -> String {
273    match contents {
274        lsp_types::Contents::MarkedString(marked_string) => marked_string_to_string(marked_string),
275        lsp_types::Contents::MarkedStringList(marked_strings) => marked_strings
276            .into_iter()
277            .map(marked_string_to_string)
278            .collect::<Vec<_>>()
279            .join("\n\n"),
280        lsp_types::Contents::MarkupContent(markup) => markup.value,
281    }
282}
283
284/// Convert a marked string to a plain string.
285#[allow(deprecated)]
286fn marked_string_to_string(marked: lsp_types::MarkedString) -> String {
287    match marked {
288        lsp_types::MarkedString::String(s) => s,
289        lsp_types::MarkedString::MarkedStringWithLanguage(ls) => {
290            format!("```{}\n{}\n```", ls.language, ls.value)
291        }
292    }
293}
294
295impl Translator {
296    /// Wait for the routed server `server_id` to finish its initial
297    /// workspace-load/indexing phase before a whole-workspace query (hover,
298    /// definition, implementation, type definition, references, rename,
299    /// completions, code actions) reaches it. Called from
300    /// [`Translator::prepare_gated_document`] for every call site declared
301    /// [`IndexingGate::Required`].
302    ///
303    /// Returns immediately, without waiting, unless
304    /// [`crate::bridge::NotificationCache::indexing_state`] currently
305    /// reports [`IndexingState::Loading`] for `server_id` -- i.e. a
306    /// recognized signal has positively indicated indexing is in progress.
307    /// A server that has never reported any readiness signal
308    /// ([`IndexingState::Unknown`]) is treated the same as
309    /// [`IndexingState::Ready`]: without evidence indexing is happening,
310    /// waiting would only add latency for servers and workspaces that have
311    /// no indexing phase at all.
312    ///
313    /// # Errors
314    ///
315    /// Returns [`Error::WorkspaceIndexing`] if the server is still
316    /// [`IndexingState::Loading`] after the translator's configured
317    /// indexing-ready timeout (default [`INDEXING_READY_TIMEOUT`],
318    /// overridable via [`Self::with_indexing_ready_timeout`]) elapses.
319    pub(super) async fn wait_for_indexing_ready(&self, server_id: &ServerId) -> Result<()> {
320        self.wait_for_indexing_ready_with(
321            server_id,
322            self.indexing_ready_timeout,
323            INDEXING_POLL_INTERVAL,
324        )
325        .await
326    }
327
328    /// [`Self::wait_for_indexing_ready`] with an injectable timeout and poll
329    /// interval, so tests can exercise the timeout path without waiting out
330    /// the real default.
331    ///
332    /// On timeout this returns [`Error::WorkspaceIndexing`] to the caller
333    /// *without* mutating any shared state -- self-healing for a stuck
334    /// `Loading` signal (a dropped `quiescent: true` notification, or a
335    /// server that stalls mid-index) is handled entirely by
336    /// [`crate::bridge::NotificationCache::indexing_state`]'s own
337    /// read-time staleness check, keyed to the signal's age rather than
338    /// this call's. Earlier revisions reset the shared entry here on
339    /// timeout, which let one caller's short timeout silently un-gate
340    /// every other concurrent or later caller before its own deadline;
341    /// never reintroduce a write here.
342    async fn wait_for_indexing_ready_with(
343        &self,
344        server_id: &ServerId,
345        timeout: Duration,
346        poll_interval: Duration,
347    ) -> Result<()> {
348        let Some(cache) = self.notification_cache.as_ref() else {
349            return Ok(());
350        };
351
352        let start = Instant::now();
353        let deadline = start + timeout;
354        loop {
355            let state = cache.lock().await.indexing_state(server_id);
356            if state != IndexingState::Loading {
357                return Ok(());
358            }
359            let remaining = deadline.saturating_duration_since(Instant::now());
360            if remaining.is_zero() {
361                return Err(Error::WorkspaceIndexing {
362                    server_id: server_id.clone(),
363                    elapsed_secs: start.elapsed().as_secs(),
364                });
365            }
366            tokio::time::sleep(poll_interval.min(remaining)).await;
367        }
368    }
369
370    /// Handle hover request.
371    ///
372    /// # Errors
373    ///
374    /// Returns an error if the LSP request fails, the file cannot be opened,
375    /// the routed server does not advertise `hoverProvider` support, or the
376    /// server is still indexing the workspace after
377    /// `INDEXING_READY_TIMEOUT`.
378    pub async fn handle_hover(&self, file_path: String, position: Position) -> Result<HoverResult> {
379        let Position { line, character } = position;
380        let (server_id, client, uri) = self
381            .prepare_gated_document(
382                &file_path,
383                ToolKind::Hover,
384                Capability::Hover,
385                IndexingGate::Required,
386            )
387            .await?;
388        let ctx = self.encoding_ctx(&server_id);
389        let lsp_position = ctx.to_lsp(&uri, line, character).await;
390        let response_uri = uri.clone();
391
392        let params = LspHoverParams {
393            text_document_position_params: TextDocumentPositionParams {
394                text_document: TextDocumentIdentifier { uri },
395                position: lsp_position,
396            },
397            work_done_progress_params: WorkDoneProgressParams::default(),
398        };
399
400        let response = client
401            .request_typed::<lsp_types::HoverRequest>(params, client.request_timeout())
402            .await?;
403
404        let result = match response {
405            Some(hover) => {
406                let contents = extract_hover_contents(hover.contents);
407                let range = match hover.range {
408                    Some(r) => Some(ctx.normalize_range(&response_uri, r).await),
409                    None => None,
410                };
411                HoverResult {
412                    contents,
413                    range,
414                    positions_degraded: ctx.positions_degraded(),
415                }
416            }
417            None => HoverResult {
418                contents: "No hover information available".to_string(),
419                range: None,
420                positions_degraded: ctx.positions_degraded(),
421            },
422        };
423
424        Ok(result)
425    }
426
427    /// Shared implementation of the go-to-X handlers (`textDocument/definition`,
428    /// `textDocument/implementation`, `textDocument/typeDefinition`): gate on
429    /// the request's capability and on indexing readiness, translate the MCP
430    /// position into LSP coordinates, dispatch the LSP request, and flatten
431    /// the response into MCP locations. Each public handler supplies its
432    /// request type via `R` plus the capability key/predicate specific to
433    /// it.
434    ///
435    /// # Errors
436    ///
437    /// Returns an error if the LSP request fails, the file cannot be opened,
438    /// the routed server does not advertise `capability` support, or the
439    /// server is still indexing the workspace after `INDEXING_READY_TIMEOUT`.
440    async fn handle_goto<R, T>(
441        &self,
442        file_path: &str,
443        position: Position,
444        tool: ToolKind,
445        capability: Capability,
446    ) -> Result<NormalizedLocations>
447    where
448        R: lsp_types::Request<Result = Option<T>>,
449        R::Params: GotoParams,
450        T: GotoResponse,
451    {
452        let Position { line, character } = position;
453        let (server_id, client, uri) = self
454            .prepare_gated_document(file_path, tool, capability, IndexingGate::Required)
455            .await?;
456        let ctx = self.encoding_ctx(&server_id);
457        let lsp_position = ctx.to_lsp(&uri, line, character).await;
458
459        let params = R::Params::from_position(TextDocumentPositionParams {
460            text_document: TextDocumentIdentifier { uri },
461            position: lsp_position,
462        });
463
464        let response = client
465            .request_typed::<R>(params, client.request_timeout())
466            .await?;
467
468        Ok(goto_response_to_locations(response, &ctx).await)
469    }
470
471    /// Handle definition request.
472    ///
473    /// # Errors
474    ///
475    /// Returns an error if the LSP request fails, the file cannot be opened,
476    /// the routed server does not advertise `definitionProvider` support, or
477    /// the server is still indexing the workspace after
478    /// `INDEXING_READY_TIMEOUT`.
479    pub async fn handle_definition(
480        &self,
481        file_path: String,
482        position: Position,
483    ) -> Result<DefinitionResult> {
484        let NormalizedLocations {
485            locations,
486            truncated,
487            positions_degraded,
488        } = self
489            .handle_goto::<lsp_types::DefinitionRequest, _>(
490                &file_path,
491                position,
492                ToolKind::Definition,
493                Capability::Definition,
494            )
495            .await?;
496
497        Ok(DefinitionResult {
498            locations,
499            truncated,
500            positions_degraded,
501        })
502    }
503
504    /// Handle references request.
505    ///
506    /// # Errors
507    ///
508    /// Returns an error if the LSP request fails, the file cannot be opened,
509    /// the routed server does not advertise `referencesProvider` support, or
510    /// the server is still indexing the workspace after
511    /// `INDEXING_READY_TIMEOUT`.
512    pub async fn handle_references(
513        &self,
514        file_path: String,
515        position: Position,
516        include_declaration: bool,
517    ) -> Result<ReferencesResult> {
518        let Position { line, character } = position;
519        let (server_id, client, uri) = self
520            .prepare_gated_document(
521                &file_path,
522                ToolKind::References,
523                Capability::References,
524                IndexingGate::Required,
525            )
526            .await?;
527        let ctx = self.encoding_ctx(&server_id);
528        let lsp_position = ctx.to_lsp(&uri, line, character).await;
529
530        let params = ReferenceParams {
531            text_document_position_params: TextDocumentPositionParams {
532                text_document: TextDocumentIdentifier { uri },
533                position: lsp_position,
534            },
535            work_done_progress_params: WorkDoneProgressParams::default(),
536            partial_result_params: PartialResultParams::default(),
537            context: ReferenceContext {
538                include_declaration,
539            },
540        };
541
542        let response = client
543            .request_typed::<lsp_types::ReferencesRequest>(params, client.request_timeout())
544            .await?;
545
546        let locations = response.unwrap_or_default();
547        let NormalizedLocations {
548            locations,
549            truncated,
550            positions_degraded,
551        } = lsp_locations_to_mcp(locations, &ctx).await;
552
553        Ok(ReferencesResult {
554            locations,
555            truncated,
556            positions_degraded,
557        })
558    }
559
560    /// Handle go-to-implementation request (`textDocument/implementation`).
561    ///
562    /// Returns the locations of trait method or interface member implementations.
563    ///
564    /// # Errors
565    ///
566    /// Returns an error if the LSP request fails, the file cannot be opened,
567    /// the routed server does not advertise `implementationProvider`
568    /// support, or the server is still indexing the workspace after
569    /// `INDEXING_READY_TIMEOUT`.
570    pub async fn handle_implementation(
571        &self,
572        file_path: String,
573        position: Position,
574    ) -> Result<LocationsResult> {
575        let NormalizedLocations {
576            locations,
577            truncated,
578            positions_degraded,
579        } = self
580            .handle_goto::<lsp_types::ImplementationRequest, _>(
581                &file_path,
582                position,
583                ToolKind::Implementation,
584                Capability::Implementation,
585            )
586            .await?;
587
588        Ok(LocationsResult {
589            locations,
590            truncated,
591            positions_degraded,
592        })
593    }
594
595    /// Handle go-to-type-definition request (`textDocument/typeDefinition`).
596    ///
597    /// Returns the type definition location of the expression at position. Distinct
598    /// from go-to-definition for variable bindings where definition and type differ.
599    ///
600    /// # Errors
601    ///
602    /// Returns an error if the LSP request fails, the file cannot be opened,
603    /// the routed server does not advertise `typeDefinitionProvider`
604    /// support, or the server is still indexing the workspace after
605    /// `INDEXING_READY_TIMEOUT`.
606    pub async fn handle_type_definition(
607        &self,
608        file_path: String,
609        position: Position,
610    ) -> Result<LocationsResult> {
611        let NormalizedLocations {
612            locations,
613            truncated,
614            positions_degraded,
615        } = self
616            .handle_goto::<lsp_types::TypeDefinitionRequest, _>(
617                &file_path,
618                position,
619                ToolKind::TypeDefinition,
620                Capability::TypeDefinition,
621            )
622            .await?;
623
624        Ok(LocationsResult {
625            locations,
626            truncated,
627            positions_degraded,
628        })
629    }
630}
631
632#[cfg(test)]
633#[allow(clippy::unwrap_used, clippy::expect_used, deprecated)]
634mod tests {
635    use std::fs;
636    use std::sync::Arc;
637    use std::time::Duration;
638
639    use tempfile::TempDir;
640    use tokio::io::BufReader;
641    use tokio::sync::Mutex;
642    use tokio::time::timeout;
643    use url::Url;
644
645    use super::*;
646    use crate::bridge::encoding::PositionEncoding;
647    use crate::bridge::translator::testing::*;
648    use crate::bridge::{NotificationCache, lock_std, path_to_uri};
649    use crate::config::ServerId;
650
651    // -----------------------------------------------------------------
652    // Indexing readiness gate (`Translator::wait_for_indexing_ready`)
653    // -----------------------------------------------------------------
654
655    #[tokio::test]
656    async fn test_wait_for_indexing_ready_without_cache_is_noop() {
657        // No wired cache (most fixtures) must never block -- see `Translator::notification_cache`'s field doc.
658        let translator = Translator::new();
659        let server_id = ServerId::from("rust");
660
661        translator
662            .wait_for_indexing_ready(&server_id)
663            .await
664            .unwrap();
665    }
666
667    #[tokio::test]
668    async fn test_wait_for_indexing_ready_unknown_state_is_noop() {
669        let translator = Translator::new()
670            .with_notification_cache(Arc::new(Mutex::new(NotificationCache::new())));
671        let server_id = ServerId::from("rust");
672
673        translator
674            .wait_for_indexing_ready(&server_id)
675            .await
676            .unwrap();
677    }
678
679    #[tokio::test]
680    async fn test_wait_for_indexing_ready_ready_state_is_noop() {
681        let cache = Arc::new(Mutex::new(NotificationCache::new()));
682        let server_id = ServerId::from("rust");
683        cache.lock().await.observe_indexing_signal(
684            &server_id,
685            "experimental/serverStatus",
686            Some(&serde_json::json!({"quiescent": true})),
687        );
688        let translator = Translator::new().with_notification_cache(cache);
689
690        translator
691            .wait_for_indexing_ready(&server_id)
692            .await
693            .unwrap();
694    }
695
696    /// #424: `with_indexing_ready_timeout` must actually change the bound
697    /// `wait_for_indexing_ready` (the public entry point, not the
698    /// timeout-injectable `_with` test helper) waits before giving up --
699    /// pins the config wiring end-to-end rather than only the constructor
700    /// storing the value.
701    #[tokio::test(start_paused = true)]
702    async fn test_wait_for_indexing_ready_uses_configured_timeout_override() {
703        let cache = Arc::new(Mutex::new(NotificationCache::new()));
704        let server_id = ServerId::from("rust");
705        cache.lock().await.observe_indexing_signal(
706            &server_id,
707            "experimental/serverStatus",
708            Some(&serde_json::json!({"quiescent": false})),
709        );
710        let translator = Translator::new()
711            .with_notification_cache(cache)
712            .with_indexing_ready_timeout(Duration::from_secs(5));
713
714        let start = Instant::now();
715        let err = translator
716            .wait_for_indexing_ready(&server_id)
717            .await
718            .unwrap_err();
719
720        assert!(matches!(err, Error::WorkspaceIndexing { elapsed_secs, .. } if elapsed_secs == 5));
721        assert_eq!(start.elapsed(), Duration::from_secs(5));
722    }
723
724    #[tokio::test]
725    async fn test_wait_for_indexing_ready_loading_times_out() {
726        let cache = Arc::new(Mutex::new(NotificationCache::new()));
727        let server_id = ServerId::from("rust");
728        cache.lock().await.observe_indexing_signal(
729            &server_id,
730            "experimental/serverStatus",
731            Some(&serde_json::json!({"quiescent": false})),
732        );
733        let translator = Translator::new().with_notification_cache(cache);
734
735        let err = translator
736            .wait_for_indexing_ready_with(
737                &server_id,
738                Duration::from_millis(50),
739                Duration::from_millis(10),
740            )
741            .await
742            .unwrap_err();
743
744        assert!(matches!(
745            err,
746            Error::WorkspaceIndexing { server_id: id, .. } if id == ServerId::from("rust")
747        ));
748    }
749
750    #[tokio::test]
751    async fn test_wait_for_indexing_ready_returns_ok_once_signaled_ready() {
752        let cache = Arc::new(Mutex::new(NotificationCache::new()));
753        let server_id = ServerId::from("rust");
754        cache.lock().await.observe_indexing_signal(
755            &server_id,
756            "experimental/serverStatus",
757            Some(&serde_json::json!({"quiescent": false})),
758        );
759        let translator = Translator::new().with_notification_cache(Arc::clone(&cache));
760
761        let waiter = {
762            let server_id = server_id.clone();
763            tokio::spawn(async move {
764                translator
765                    .wait_for_indexing_ready_with(
766                        &server_id,
767                        Duration::from_secs(5),
768                        Duration::from_millis(10),
769                    )
770                    .await
771            })
772        };
773
774        tokio::time::sleep(Duration::from_millis(30)).await;
775        cache.lock().await.observe_indexing_signal(
776            &server_id,
777            "experimental/serverStatus",
778            Some(&serde_json::json!({"quiescent": true})),
779        );
780
781        timeout(Duration::from_secs(1), waiter)
782            .await
783            .expect("waiter task timed out")
784            .expect("waiter task panicked")
785            .expect("expected Ok once quiescent");
786    }
787
788    /// End-to-end: a real handler (`handle_hover`) must surface
789    /// `Error::WorkspaceIndexing` -- not an empty/`null` result -- when the
790    /// routed server is still `Loading`, without ever reaching the fake LSP
791    /// server. Runs under paused virtual time so it does not actually wait
792    /// out the real `INDEXING_READY_TIMEOUT`.
793    #[tokio::test(start_paused = true)]
794    async fn test_handle_hover_returns_workspace_indexing_error_when_loading() {
795        let dir = TempDir::new().unwrap();
796        let server_id = ServerId::from("rust");
797        let caps = lsp_types::ServerCapabilities {
798            hover_provider: Some(lsp_types::HoverProvider::Bool(true)),
799            ..Default::default()
800        };
801        let (translator, _server) = translator_with_capabilities(&dir, &server_id, caps);
802
803        let cache = Arc::new(Mutex::new(NotificationCache::new()));
804        cache.lock().await.observe_indexing_signal(
805            &server_id,
806            "experimental/serverStatus",
807            Some(&serde_json::json!({"quiescent": false})),
808        );
809        let translator = translator.with_notification_cache(cache);
810
811        let path = dir.path().join("main.rs");
812        fs::write(&path, "fn main() {}").unwrap();
813
814        let err = translator
815            .handle_hover(path.to_string_lossy().to_string(), pos(1, 1))
816            .await
817            .unwrap_err();
818
819        assert!(matches!(
820            err,
821            Error::WorkspaceIndexing { server_id: id, elapsed_secs: 30 } if id == server_id
822        ));
823    }
824
825    /// Companion to the timeout test above: when the cache reports `Ready`,
826    /// `handle_hover` must dispatch normally with no added delay.
827    #[tokio::test]
828    async fn test_handle_hover_dispatches_when_indexing_ready() {
829        let dir = TempDir::new().unwrap();
830        let server_id = ServerId::from("rust");
831        let caps = lsp_types::ServerCapabilities {
832            hover_provider: Some(lsp_types::HoverProvider::Bool(true)),
833            ..Default::default()
834        };
835        let (translator, mut server) = translator_with_capabilities(&dir, &server_id, caps);
836
837        let cache = Arc::new(Mutex::new(NotificationCache::new()));
838        cache.lock().await.observe_indexing_signal(
839            &server_id,
840            "experimental/serverStatus",
841            Some(&serde_json::json!({"quiescent": true})),
842        );
843        let translator = Arc::new(translator.with_notification_cache(cache));
844
845        let path = dir.path().join("main.rs");
846        fs::write(&path, "fn main() {}").unwrap();
847
848        let handle = {
849            let translator = Arc::clone(&translator);
850            let path = path.to_string_lossy().to_string();
851            tokio::spawn(async move { translator.handle_hover(path, pos(1, 1)).await })
852        };
853
854        let mut wire = BufReader::new(&mut server.write_stdout);
855        let opened = read_framed_message(&mut wire).await;
856        assert_eq!(opened["method"], "textDocument/didOpen");
857        let request = read_framed_message(&mut wire).await;
858        assert_eq!(request["method"], "textDocument/hover");
859
860        write_response(
861            &mut server.read_half_stdin,
862            &request["id"],
863            serde_json::json!({
864                "contents": {"kind": "markdown", "value": "hover text"}
865            }),
866        )
867        .await;
868
869        let result = handle.await.unwrap().unwrap();
870        assert_eq!(result.contents, "hover text");
871    }
872
873    /// End-to-end: `handle_definition` must surface `Error::WorkspaceIndexing`
874    /// while the routed server is still `Loading`, without reaching the fake
875    /// LSP server.
876    #[tokio::test(start_paused = true)]
877    async fn test_handle_definition_returns_workspace_indexing_error_when_loading() {
878        let dir = TempDir::new().unwrap();
879        let server_id = ServerId::from("rust");
880        let caps = lsp_types::ServerCapabilities {
881            definition_provider: Some(lsp_types::DefinitionProvider::Bool(true)),
882            ..Default::default()
883        };
884        let (translator, _server) = translator_with_capabilities(&dir, &server_id, caps);
885
886        let cache = Arc::new(Mutex::new(NotificationCache::new()));
887        cache.lock().await.observe_indexing_signal(
888            &server_id,
889            "experimental/serverStatus",
890            Some(&serde_json::json!({"quiescent": false})),
891        );
892        let translator = translator.with_notification_cache(cache);
893
894        let path = dir.path().join("main.rs");
895        fs::write(&path, "fn main() {}").unwrap();
896
897        let err = translator
898            .handle_definition(path.to_string_lossy().to_string(), pos(1, 1))
899            .await
900            .unwrap_err();
901
902        assert!(matches!(
903            err,
904            Error::WorkspaceIndexing { server_id: id, .. } if id == server_id
905        ));
906    }
907
908    /// Companion: when the cache reports `Ready`, `handle_definition` must
909    /// dispatch normally.
910    #[tokio::test]
911    async fn test_handle_definition_dispatches_when_indexing_ready() {
912        let dir = TempDir::new().unwrap();
913        let server_id = ServerId::from("rust");
914        let caps = lsp_types::ServerCapabilities {
915            definition_provider: Some(lsp_types::DefinitionProvider::Bool(true)),
916            ..Default::default()
917        };
918        let (translator, mut server) = translator_with_capabilities(&dir, &server_id, caps);
919
920        let cache = Arc::new(Mutex::new(NotificationCache::new()));
921        cache.lock().await.observe_indexing_signal(
922            &server_id,
923            "experimental/serverStatus",
924            Some(&serde_json::json!({"quiescent": true})),
925        );
926        let translator = Arc::new(translator.with_notification_cache(cache));
927
928        let path = dir.path().join("main.rs");
929        fs::write(&path, "fn main() {}").unwrap();
930
931        let handle = {
932            let translator = Arc::clone(&translator);
933            let path = path.to_string_lossy().to_string();
934            tokio::spawn(async move { translator.handle_definition(path, pos(1, 1)).await })
935        };
936
937        let mut wire = BufReader::new(&mut server.write_stdout);
938        let opened = read_framed_message(&mut wire).await;
939        assert_eq!(opened["method"], "textDocument/didOpen");
940        let request = read_framed_message(&mut wire).await;
941        assert_eq!(request["method"], "textDocument/definition");
942
943        write_response(
944            &mut server.read_half_stdin,
945            &request["id"],
946            serde_json::Value::Null,
947        )
948        .await;
949
950        let result = handle.await.unwrap().unwrap();
951        assert!(result.locations.is_empty());
952    }
953
954    /// End-to-end: `handle_references` must surface `Error::WorkspaceIndexing`
955    /// while the routed server is still `Loading`, without reaching the fake
956    /// LSP server.
957    #[tokio::test(start_paused = true)]
958    async fn test_handle_references_returns_workspace_indexing_error_when_loading() {
959        let dir = TempDir::new().unwrap();
960        let server_id = ServerId::from("rust");
961        let caps = lsp_types::ServerCapabilities {
962            references_provider: Some(lsp_types::ReferencesProvider::Bool(true)),
963            ..Default::default()
964        };
965        let (translator, _server) = translator_with_capabilities(&dir, &server_id, caps);
966
967        let cache = Arc::new(Mutex::new(NotificationCache::new()));
968        cache.lock().await.observe_indexing_signal(
969            &server_id,
970            "experimental/serverStatus",
971            Some(&serde_json::json!({"quiescent": false})),
972        );
973        let translator = translator.with_notification_cache(cache);
974
975        let path = dir.path().join("main.rs");
976        fs::write(&path, "fn main() {}").unwrap();
977
978        let err = translator
979            .handle_references(path.to_string_lossy().to_string(), pos(1, 1), true)
980            .await
981            .unwrap_err();
982
983        assert!(matches!(
984            err,
985            Error::WorkspaceIndexing { server_id: id, .. } if id == server_id
986        ));
987    }
988
989    /// Companion: when the cache reports `Ready`, `handle_references` must
990    /// dispatch normally.
991    #[tokio::test]
992    async fn test_handle_references_dispatches_when_indexing_ready() {
993        let dir = TempDir::new().unwrap();
994        let server_id = ServerId::from("rust");
995        let caps = lsp_types::ServerCapabilities {
996            references_provider: Some(lsp_types::ReferencesProvider::Bool(true)),
997            ..Default::default()
998        };
999        let (translator, mut server) = translator_with_capabilities(&dir, &server_id, caps);
1000
1001        let cache = Arc::new(Mutex::new(NotificationCache::new()));
1002        cache.lock().await.observe_indexing_signal(
1003            &server_id,
1004            "experimental/serverStatus",
1005            Some(&serde_json::json!({"quiescent": true})),
1006        );
1007        let translator = Arc::new(translator.with_notification_cache(cache));
1008
1009        let path = dir.path().join("main.rs");
1010        fs::write(&path, "fn main() {}").unwrap();
1011
1012        let handle = {
1013            let translator = Arc::clone(&translator);
1014            let path = path.to_string_lossy().to_string();
1015            tokio::spawn(async move { translator.handle_references(path, pos(1, 1), true).await })
1016        };
1017
1018        let mut wire = BufReader::new(&mut server.write_stdout);
1019        let opened = read_framed_message(&mut wire).await;
1020        assert_eq!(opened["method"], "textDocument/didOpen");
1021        let request = read_framed_message(&mut wire).await;
1022        assert_eq!(request["method"], "textDocument/references");
1023
1024        write_response(
1025            &mut server.read_half_stdin,
1026            &request["id"],
1027            serde_json::Value::Null,
1028        )
1029        .await;
1030
1031        let result = handle.await.unwrap().unwrap();
1032        assert!(result.locations.is_empty());
1033    }
1034
1035    /// S3 fix: `handle_implementation` shares `handle_goto` with
1036    /// `handle_definition` and must now be gated the same way --
1037    /// `textDocument/implementation` needs the whole-crate trait-impl index,
1038    /// which is at least as index-dependent as `definition`.
1039    #[tokio::test(start_paused = true)]
1040    async fn test_handle_implementation_returns_workspace_indexing_error_when_loading() {
1041        let dir = TempDir::new().unwrap();
1042        let server_id = ServerId::from("rust");
1043        let caps = lsp_types::ServerCapabilities {
1044            implementation_provider: Some(lsp_types::ImplementationProvider::Bool(true)),
1045            ..Default::default()
1046        };
1047        let (translator, _server) = translator_with_capabilities(&dir, &server_id, caps);
1048
1049        let cache = Arc::new(Mutex::new(NotificationCache::new()));
1050        cache.lock().await.observe_indexing_signal(
1051            &server_id,
1052            "experimental/serverStatus",
1053            Some(&serde_json::json!({"quiescent": false})),
1054        );
1055        let translator = translator.with_notification_cache(cache);
1056
1057        let path = dir.path().join("main.rs");
1058        fs::write(&path, "fn main() {}").unwrap();
1059
1060        let err = translator
1061            .handle_implementation(path.to_string_lossy().to_string(), pos(1, 1))
1062            .await
1063            .unwrap_err();
1064
1065        assert!(matches!(
1066            err,
1067            Error::WorkspaceIndexing { server_id: id, .. } if id == server_id
1068        ));
1069    }
1070
1071    /// S3 fix, companion for `handle_type_definition`.
1072    #[tokio::test(start_paused = true)]
1073    async fn test_handle_type_definition_returns_workspace_indexing_error_when_loading() {
1074        let dir = TempDir::new().unwrap();
1075        let server_id = ServerId::from("rust");
1076        let caps = lsp_types::ServerCapabilities {
1077            type_definition_provider: Some(lsp_types::TypeDefinitionProvider::Bool(true)),
1078            ..Default::default()
1079        };
1080        let (translator, _server) = translator_with_capabilities(&dir, &server_id, caps);
1081
1082        let cache = Arc::new(Mutex::new(NotificationCache::new()));
1083        cache.lock().await.observe_indexing_signal(
1084            &server_id,
1085            "experimental/serverStatus",
1086            Some(&serde_json::json!({"quiescent": false})),
1087        );
1088        let translator = translator.with_notification_cache(cache);
1089
1090        let path = dir.path().join("main.rs");
1091        fs::write(&path, "fn main() {}").unwrap();
1092
1093        let err = translator
1094            .handle_type_definition(path.to_string_lossy().to_string(), pos(1, 1))
1095            .await
1096            .unwrap_err();
1097
1098        assert!(matches!(
1099            err,
1100            Error::WorkspaceIndexing { server_id: id, .. } if id == server_id
1101        ));
1102    }
1103
1104    /// A timed-out wait must return `Error::WorkspaceIndexing` to its own
1105    /// caller without mutating the shared cache entry -- a fixed-in-review
1106    /// regression had the timeout handler reset the entry to `Unknown`,
1107    /// which released every other concurrent/later caller early (see
1108    /// `test_wait_for_indexing_ready_one_callers_timeout_does_not_release_another`
1109    /// for the direct reproduction). Self-healing for a genuinely stuck
1110    /// signal now lives entirely in
1111    /// `NotificationCache::indexing_state`'s own staleness check.
1112    #[tokio::test]
1113    async fn test_wait_for_indexing_ready_timeout_does_not_mutate_shared_state() {
1114        let cache = Arc::new(Mutex::new(NotificationCache::new()));
1115        let server_id = ServerId::from("rust");
1116        cache.lock().await.observe_indexing_signal(
1117            &server_id,
1118            "experimental/serverStatus",
1119            Some(&serde_json::json!({"quiescent": false})),
1120        );
1121        let translator = Translator::new().with_notification_cache(Arc::clone(&cache));
1122
1123        translator
1124            .wait_for_indexing_ready_with(
1125                &server_id,
1126                Duration::from_millis(30),
1127                Duration::from_millis(10),
1128            )
1129            .await
1130            .unwrap_err();
1131
1132        assert_eq!(
1133            cache.lock().await.indexing_state(&server_id),
1134            IndexingState::Loading,
1135            "a timed-out wait must not touch the shared entry -- it is still fresh, so it must \
1136             still read as Loading for any other caller"
1137        );
1138    }
1139
1140    /// Direct reproduction of the self-heal race: a short-timeout waiter's
1141    /// own timeout must never resolve a concurrent long-timeout waiter's
1142    /// independent wait early. Before the fix, both waiters observed the
1143    /// same shared `IndexingState`, and the short waiter's timeout handler
1144    /// reset it to `Unknown` as a side effect -- silently un-gating the
1145    /// long waiter tens of seconds before its own deadline.
1146    #[tokio::test]
1147    async fn test_wait_for_indexing_ready_one_callers_timeout_does_not_release_another() {
1148        let cache = Arc::new(Mutex::new(NotificationCache::new()));
1149        let server_id = ServerId::from("rust");
1150        cache.lock().await.observe_indexing_signal(
1151            &server_id,
1152            "experimental/serverStatus",
1153            Some(&serde_json::json!({"quiescent": false})),
1154        );
1155        let translator = Arc::new(Translator::new().with_notification_cache(Arc::clone(&cache)));
1156
1157        let short = {
1158            let translator = Arc::clone(&translator);
1159            let server_id = server_id.clone();
1160            tokio::spawn(async move {
1161                translator
1162                    .wait_for_indexing_ready_with(
1163                        &server_id,
1164                        Duration::from_millis(80),
1165                        Duration::from_millis(10),
1166                    )
1167                    .await
1168            })
1169        };
1170        let long = {
1171            let translator = Arc::clone(&translator);
1172            let server_id = server_id.clone();
1173            tokio::spawn(async move {
1174                translator
1175                    .wait_for_indexing_ready_with(
1176                        &server_id,
1177                        Duration::from_secs(30),
1178                        Duration::from_millis(10),
1179                    )
1180                    .await
1181            })
1182        };
1183
1184        let short_result = short.await.unwrap();
1185        assert!(
1186            matches!(short_result, Err(Error::WorkspaceIndexing { .. })),
1187            "the short-timeout waiter must time out on its own schedule, got {short_result:?}"
1188        );
1189
1190        // Well past the short waiter's 80ms deadline, nowhere near the long
1191        // waiter's 30s one.
1192        tokio::time::sleep(Duration::from_millis(150)).await;
1193        assert!(
1194            !long.is_finished(),
1195            "a concurrent caller's short timeout must never resolve another caller's \
1196             independent wait early"
1197        );
1198        long.abort();
1199    }
1200
1201    #[test]
1202    fn test_extract_hover_contents_string() {
1203        let marked_string = lsp_types::MarkedString::String("Test hover".to_string());
1204        let contents = lsp_types::Contents::MarkedString(marked_string);
1205        let result = extract_hover_contents(contents);
1206        assert_eq!(result, "Test hover");
1207    }
1208
1209    #[test]
1210    fn test_extract_hover_contents_language_string() {
1211        let marked_string = lsp_types::MarkedString::MarkedStringWithLanguage(
1212            lsp_types::MarkedStringWithLanguage {
1213                language: "rust".to_string(),
1214                value: "fn main() {}".to_string(),
1215            },
1216        );
1217        let contents = lsp_types::Contents::MarkedString(marked_string);
1218        let result = extract_hover_contents(contents);
1219        assert_eq!(result, "```rust\nfn main() {}\n```");
1220    }
1221
1222    #[test]
1223    fn test_extract_hover_contents_markup() {
1224        let markup = lsp_types::MarkupContent {
1225            kind: lsp_types::MarkupKind::Markdown,
1226            value: "# Documentation".to_string(),
1227        };
1228        let contents = lsp_types::Contents::MarkupContent(markup);
1229        let result = extract_hover_contents(contents);
1230        assert_eq!(result, "# Documentation");
1231    }
1232
1233    /// Success-path coverage for `handle_definition` through the
1234    /// `Definition::Location` -> `GotoKind::Definition` arm, pinning the
1235    /// `GotoResponse` impl for `DefinitionResponse`.
1236    #[tokio::test]
1237    async fn test_handle_definition_flattens_single_location() {
1238        let dir = TempDir::new().unwrap();
1239        let server_id = ServerId::from("rust");
1240        let caps = lsp_types::ServerCapabilities {
1241            definition_provider: Some(lsp_types::DefinitionProvider::Bool(true)),
1242            ..Default::default()
1243        };
1244        let (translator, mut server) = translator_with_capabilities(&dir, &server_id, caps);
1245
1246        let path = dir.path().join("main.rs");
1247        fs::write(&path, "fn main() {}").unwrap();
1248        let target_path = dir.path().join("target.rs");
1249        fs::write(&target_path, "fn target() {}").unwrap();
1250        let target_uri = Url::from_file_path(&target_path).unwrap().to_string();
1251
1252        let translator = Arc::new(translator);
1253        let handle = {
1254            let translator = Arc::clone(&translator);
1255            let path = path.to_string_lossy().to_string();
1256            tokio::spawn(async move {
1257                translator
1258                    .handle_definition(
1259                        path,
1260                        Position {
1261                            line: 1,
1262                            character: 1,
1263                        },
1264                    )
1265                    .await
1266            })
1267        };
1268
1269        let mut wire = BufReader::new(&mut server.write_stdout);
1270        let opened = read_framed_message(&mut wire).await;
1271        assert_eq!(opened["method"], "textDocument/didOpen");
1272        let request = read_framed_message(&mut wire).await;
1273        assert_eq!(request["method"], "textDocument/definition");
1274
1275        write_response(
1276            &mut server.read_half_stdin,
1277            &request["id"],
1278            serde_json::json!({
1279                "uri": target_uri,
1280                "range": {
1281                    "start": {"line": 0, "character": 0},
1282                    "end": {"line": 0, "character": 6}
1283                }
1284            }),
1285        )
1286        .await;
1287
1288        let result = timeout(Duration::from_secs(2), handle)
1289            .await
1290            .expect("handle_definition should not hang")
1291            .unwrap()
1292            .unwrap();
1293
1294        assert_eq!(result.locations.len(), 1);
1295        assert_eq!(result.locations[0].uri, target_uri);
1296        assert!(
1297            !result.locations[0].out_of_workspace,
1298            "a definition location inside the workspace root must not be marked out_of_workspace"
1299        );
1300    }
1301
1302    /// #415 (revised per critic C1): a definition location whose URI falls
1303    /// outside every configured workspace root must still be returned --
1304    /// goto-definition into the standard library or a crates.io dependency
1305    /// is normal, expected navigation, not an attack. The untrusted-URI
1306    /// concern is instead covered downstream, by the inbound
1307    /// `validate_path_against_roots` gate any subsequent open/read of the
1308    /// path would hit.
1309    #[tokio::test]
1310    async fn test_handle_definition_does_not_filter_out_of_workspace_location() {
1311        let dir = TempDir::new().unwrap();
1312        let server_id = ServerId::from("rust");
1313        let caps = lsp_types::ServerCapabilities {
1314            definition_provider: Some(lsp_types::DefinitionProvider::Bool(true)),
1315            ..Default::default()
1316        };
1317        let (translator, mut server) = translator_with_capabilities(&dir, &server_id, caps);
1318
1319        let path = dir.path().join("main.rs");
1320        fs::write(&path, "fn main() {}").unwrap();
1321        let outside_uri = "file:///outside/workspace/stdlib.rs";
1322
1323        let translator = Arc::new(translator);
1324        let handle = {
1325            let translator = Arc::clone(&translator);
1326            let path = path.to_string_lossy().to_string();
1327            tokio::spawn(async move {
1328                translator
1329                    .handle_definition(
1330                        path,
1331                        Position {
1332                            line: 1,
1333                            character: 1,
1334                        },
1335                    )
1336                    .await
1337            })
1338        };
1339
1340        let mut wire = BufReader::new(&mut server.write_stdout);
1341        let opened = read_framed_message(&mut wire).await;
1342        assert_eq!(opened["method"], "textDocument/didOpen");
1343        let request = read_framed_message(&mut wire).await;
1344        assert_eq!(request["method"], "textDocument/definition");
1345
1346        write_response(
1347            &mut server.read_half_stdin,
1348            &request["id"],
1349            serde_json::json!({
1350                "uri": outside_uri,
1351                "range": {
1352                    "start": {"line": 0, "character": 0},
1353                    "end": {"line": 0, "character": 6}
1354                }
1355            }),
1356        )
1357        .await;
1358
1359        let result = timeout(Duration::from_secs(2), handle)
1360            .await
1361            .expect("handle_definition should not hang")
1362            .unwrap()
1363            .unwrap();
1364
1365        assert_eq!(
1366            result.locations.len(),
1367            1,
1368            "an out-of-workspace definition location (e.g. stdlib/a dependency) must be \
1369             returned, not dropped"
1370        );
1371        assert_eq!(result.locations[0].uri, outside_uri);
1372        assert!(
1373            result.locations[0].out_of_workspace,
1374            "a definition location outside every workspace root must be marked out_of_workspace"
1375        );
1376    }
1377
1378    /// #415 (revised per critic C1) companion for `handle_references`: an
1379    /// out-of-workspace location must pass through unfiltered, same as an
1380    /// in-workspace one -- see `test_handle_definition_does_not_filter_out_of_workspace_location`.
1381    #[tokio::test]
1382    async fn test_handle_references_does_not_filter_out_of_workspace_location() {
1383        let dir = TempDir::new().unwrap();
1384        let server_id = ServerId::from("rust");
1385        let caps = lsp_types::ServerCapabilities {
1386            references_provider: Some(lsp_types::ReferencesProvider::Bool(true)),
1387            ..Default::default()
1388        };
1389        let (translator, mut server) = translator_with_capabilities(&dir, &server_id, caps);
1390
1391        let path = dir.path().join("main.rs");
1392        fs::write(&path, "fn main() {}").unwrap();
1393        let inside_path = dir.path().join("inside.rs");
1394        fs::write(&inside_path, "fn used() {}").unwrap();
1395        let inside_uri = Url::from_file_path(&inside_path).unwrap().to_string();
1396        let outside_uri = "file:///outside/workspace/stdlib.rs";
1397
1398        let translator = Arc::new(translator);
1399        let handle = {
1400            let translator = Arc::clone(&translator);
1401            let path = path.to_string_lossy().to_string();
1402            tokio::spawn(async move { translator.handle_references(path, pos(1, 1), true).await })
1403        };
1404
1405        let mut wire = BufReader::new(&mut server.write_stdout);
1406        let opened = read_framed_message(&mut wire).await;
1407        assert_eq!(opened["method"], "textDocument/didOpen");
1408        let request = read_framed_message(&mut wire).await;
1409        assert_eq!(request["method"], "textDocument/references");
1410
1411        write_response(
1412            &mut server.read_half_stdin,
1413            &request["id"],
1414            serde_json::json!([
1415                {
1416                    "uri": inside_uri,
1417                    "range": {
1418                        "start": {"line": 0, "character": 0},
1419                        "end": {"line": 0, "character": 4}
1420                    }
1421                },
1422                {
1423                    "uri": outside_uri,
1424                    "range": {
1425                        "start": {"line": 0, "character": 0},
1426                        "end": {"line": 0, "character": 4}
1427                    }
1428                }
1429            ]),
1430        )
1431        .await;
1432
1433        let result = timeout(Duration::from_secs(2), handle)
1434            .await
1435            .expect("handle_references should not hang")
1436            .unwrap()
1437            .unwrap();
1438
1439        assert_eq!(
1440            result.locations.len(),
1441            2,
1442            "both the in-workspace and out-of-workspace locations must survive"
1443        );
1444        assert!(result.locations.iter().any(|l| l.uri == inside_uri));
1445        assert!(result.locations.iter().any(|l| l.uri == outside_uri));
1446        assert!(
1447            !result
1448                .locations
1449                .iter()
1450                .find(|l| l.uri == inside_uri)
1451                .unwrap()
1452                .out_of_workspace,
1453            "an in-workspace reference location must not be marked out_of_workspace"
1454        );
1455        assert!(
1456            result
1457                .locations
1458                .iter()
1459                .find(|l| l.uri == outside_uri)
1460                .unwrap()
1461                .out_of_workspace,
1462            "an out-of-workspace reference location must be marked out_of_workspace"
1463        );
1464    }
1465
1466    /// Regression for #474/M4: `get_references`' tool description no longer
1467    /// promises "all" references, since a response past
1468    /// `MAX_NORMALIZED_LOCATIONS` is capped -- the client must be able to
1469    /// detect that via `ReferencesResult::truncated` rather than silently
1470    /// receiving a partial result that looks complete.
1471    #[tokio::test]
1472    async fn test_handle_references_sets_truncated_flag_past_cap() {
1473        let dir = TempDir::new().unwrap();
1474        let server_id = ServerId::from("rust");
1475        let caps = lsp_types::ServerCapabilities {
1476            references_provider: Some(lsp_types::ReferencesProvider::Bool(true)),
1477            ..Default::default()
1478        };
1479        let (translator, mut server) = translator_with_capabilities(&dir, &server_id, caps);
1480
1481        let path = dir.path().join("main.rs");
1482        fs::write(&path, "fn main() {}").unwrap();
1483        let uri = Url::from_file_path(&path).unwrap().to_string();
1484
1485        let translator = Arc::new(translator);
1486        let handle = {
1487            let translator = Arc::clone(&translator);
1488            let path = path.to_string_lossy().to_string();
1489            tokio::spawn(async move { translator.handle_references(path, pos(1, 1), true).await })
1490        };
1491
1492        let mut wire = BufReader::new(&mut server.write_stdout);
1493        let opened = read_framed_message(&mut wire).await;
1494        assert_eq!(opened["method"], "textDocument/didOpen");
1495        let request = read_framed_message(&mut wire).await;
1496        assert_eq!(request["method"], "textDocument/references");
1497
1498        let locations: Vec<serde_json::Value> = (0..MAX_NORMALIZED_LOCATIONS + 500)
1499            .map(|_| {
1500                serde_json::json!({
1501                    "uri": uri,
1502                    "range": {
1503                        "start": {"line": 0, "character": 0},
1504                        "end": {"line": 0, "character": 4}
1505                    }
1506                })
1507            })
1508            .collect();
1509        write_response(
1510            &mut server.read_half_stdin,
1511            &request["id"],
1512            serde_json::json!(locations),
1513        )
1514        .await;
1515
1516        let result = timeout(Duration::from_secs(5), handle)
1517            .await
1518            .expect("handle_references should not hang")
1519            .unwrap()
1520            .unwrap();
1521
1522        assert_eq!(result.locations.len(), MAX_NORMALIZED_LOCATIONS);
1523        assert!(
1524            result.truncated,
1525            "a references response past MAX_NORMALIZED_LOCATIONS must set truncated: true"
1526        );
1527    }
1528
1529    /// Success-path coverage for `handle_implementation` through the
1530    /// `Definition::LocationList` -> `GotoKind::Definition` arm, pinning the
1531    /// `GotoResponse` impl for `ImplementationResponse`.
1532    #[tokio::test]
1533    async fn test_handle_implementation_flattens_location_list() {
1534        let dir = TempDir::new().unwrap();
1535        let server_id = ServerId::from("rust");
1536        let caps = lsp_types::ServerCapabilities {
1537            implementation_provider: Some(lsp_types::ImplementationProvider::Bool(true)),
1538            ..Default::default()
1539        };
1540        let (translator, mut server) = translator_with_capabilities(&dir, &server_id, caps);
1541
1542        let path = dir.path().join("main.rs");
1543        fs::write(&path, "fn main() {}").unwrap();
1544        let first_impl_path = dir.path().join("impl_a.rs");
1545        fs::write(&first_impl_path, "struct A;").unwrap();
1546        let first_impl_uri = Url::from_file_path(&first_impl_path).unwrap().to_string();
1547        let second_impl_path = dir.path().join("impl_b.rs");
1548        fs::write(&second_impl_path, "struct B;").unwrap();
1549        let second_impl_uri = Url::from_file_path(&second_impl_path).unwrap().to_string();
1550
1551        let translator = Arc::new(translator);
1552        let handle = {
1553            let translator = Arc::clone(&translator);
1554            let path = path.to_string_lossy().to_string();
1555            tokio::spawn(async move {
1556                translator
1557                    .handle_implementation(
1558                        path,
1559                        Position {
1560                            line: 1,
1561                            character: 1,
1562                        },
1563                    )
1564                    .await
1565            })
1566        };
1567
1568        let mut wire = BufReader::new(&mut server.write_stdout);
1569        let opened = read_framed_message(&mut wire).await;
1570        assert_eq!(opened["method"], "textDocument/didOpen");
1571        let request = read_framed_message(&mut wire).await;
1572        assert_eq!(request["method"], "textDocument/implementation");
1573
1574        write_response(
1575            &mut server.read_half_stdin,
1576            &request["id"],
1577            serde_json::json!([
1578                {
1579                    "uri": first_impl_uri,
1580                    "range": {
1581                        "start": {"line": 0, "character": 0},
1582                        "end": {"line": 0, "character": 9}
1583                    }
1584                },
1585                {
1586                    "uri": second_impl_uri,
1587                    "range": {
1588                        "start": {"line": 0, "character": 0},
1589                        "end": {"line": 0, "character": 9}
1590                    }
1591                }
1592            ]),
1593        )
1594        .await;
1595
1596        let result = timeout(Duration::from_secs(2), handle)
1597            .await
1598            .expect("handle_implementation should not hang")
1599            .unwrap()
1600            .unwrap();
1601
1602        assert_eq!(result.locations.len(), 2);
1603        assert_eq!(result.locations[0].uri, first_impl_uri);
1604        assert_eq!(result.locations[1].uri, second_impl_uri);
1605    }
1606
1607    /// Success-path coverage for `handle_type_definition` through the
1608    /// `DefinitionLinkList` -> `GotoKind::DefinitionLinkList` arm, pinning
1609    /// the `GotoResponse` impl for `TypeDefinitionResponse` and the
1610    /// `definition_link_to_location` mapping (`target_selection_range`, not
1611    /// `target_range`).
1612    #[tokio::test]
1613    async fn test_handle_type_definition_flattens_definition_link_list() {
1614        let dir = TempDir::new().unwrap();
1615        let server_id = ServerId::from("rust");
1616        let caps = lsp_types::ServerCapabilities {
1617            type_definition_provider: Some(lsp_types::TypeDefinitionProvider::Bool(true)),
1618            ..Default::default()
1619        };
1620        let (translator, mut server) = translator_with_capabilities(&dir, &server_id, caps);
1621
1622        let path = dir.path().join("main.rs");
1623        fs::write(&path, "fn main() {}").unwrap();
1624        let target_path = dir.path().join("target_type.rs");
1625        fs::write(&target_path, "struct TargetType;").unwrap();
1626        let target_uri = Url::from_file_path(&target_path).unwrap().to_string();
1627
1628        let translator = Arc::new(translator);
1629        let handle = {
1630            let translator = Arc::clone(&translator);
1631            let path = path.to_string_lossy().to_string();
1632            tokio::spawn(async move {
1633                translator
1634                    .handle_type_definition(
1635                        path,
1636                        Position {
1637                            line: 1,
1638                            character: 1,
1639                        },
1640                    )
1641                    .await
1642            })
1643        };
1644
1645        let mut wire = BufReader::new(&mut server.write_stdout);
1646        let opened = read_framed_message(&mut wire).await;
1647        assert_eq!(opened["method"], "textDocument/didOpen");
1648        let request = read_framed_message(&mut wire).await;
1649        assert_eq!(request["method"], "textDocument/typeDefinition");
1650
1651        write_response(
1652            &mut server.read_half_stdin,
1653            &request["id"],
1654            serde_json::json!([{
1655                "targetUri": target_uri,
1656                "targetRange": {
1657                    "start": {"line": 0, "character": 0},
1658                    "end": {"line": 0, "character": 18}
1659                },
1660                "targetSelectionRange": {
1661                    "start": {"line": 0, "character": 7},
1662                    "end": {"line": 0, "character": 17}
1663                }
1664            }]),
1665        )
1666        .await;
1667
1668        let result = timeout(Duration::from_secs(2), handle)
1669            .await
1670            .expect("handle_type_definition should not hang")
1671            .unwrap()
1672            .unwrap();
1673
1674        assert_eq!(result.locations.len(), 1);
1675        assert_eq!(result.locations[0].uri, target_uri);
1676        assert_eq!(result.locations[0].range.start.character, 8);
1677    }
1678
1679    // -----------------------------------------------------------------
1680    // Resource-amplification defenses (#474)
1681    // -----------------------------------------------------------------
1682
1683    /// Regression for #474's exact attack scenario: many `Location`s
1684    /// clustered onto a handful of distinct `(file, line)` pairs must cost
1685    /// one disk read per distinct pair, not one per location. Proven through
1686    /// the real `lsp_locations_to_mcp` entry point shared by `handle_goto`
1687    /// and `handle_references` -- not by calling the cache-backed helper
1688    /// directly -- and via the cache's own size, which is a direct count of
1689    /// how many times the disk-read fallback actually ran.
1690    #[tokio::test]
1691    async fn test_lsp_locations_to_mcp_reads_disk_once_per_distinct_file_line() {
1692        let dir = TempDir::new().unwrap();
1693        let mut uris = Vec::new();
1694        for i in 0..3 {
1695            let path = dir.path().join(format!("file{i}.rs"));
1696            fs::write(&path, "hello").unwrap();
1697            uris.push(path_to_uri(&path).unwrap());
1698        }
1699
1700        let ctx = test_ctx_with(PositionEncoding::Utf8);
1701        // 300 locations, but only 3 distinct (file, line) pairs.
1702        let locs: Vec<lsp_types::Location> = (0..300)
1703            .map(|i| lsp_types::Location {
1704                uri: uris[i % 3].clone(),
1705                range: lsp_types::Range {
1706                    start: lsp_types::Position {
1707                        line: 0,
1708                        character: 0,
1709                    },
1710                    end: lsp_types::Position {
1711                        line: 0,
1712                        character: 3,
1713                    },
1714                },
1715            })
1716            .collect();
1717
1718        let result = lsp_locations_to_mcp(locs, &ctx).await;
1719
1720        assert_eq!(result.locations.len(), 300);
1721        assert!(!result.truncated);
1722        assert!(
1723            result.locations.iter().all(|l| l.range.end.character == 4),
1724            "MCP columns are 1-based, so LSP byte offset 3 in all-ASCII \"hello\" must convert \
1725             to 4"
1726        );
1727        assert_eq!(
1728            lock_std(&ctx.line_cache).entries.len(),
1729            3,
1730            "300 locations across 3 distinct files must populate the cache with exactly 3 \
1731             entries (one disk read per distinct file/line), not one per location"
1732        );
1733    }
1734
1735    /// Regression for #474: without a cap, a response naming an unbounded
1736    /// number of locations would drive an unbounded number of range
1737    /// conversions. `Utf16` needs no disk read at all (see `test_ctx`), so
1738    /// this isolates the truncation itself from I/O cost -- a response well
1739    /// past `MAX_NORMALIZED_LOCATIONS` must be truncated to it, not hang,
1740    /// OOM, or panic.
1741    #[tokio::test]
1742    async fn test_lsp_locations_to_mcp_truncates_to_max_normalized_locations() {
1743        let ctx = test_ctx();
1744        let uri = test_uri();
1745        let locs: Vec<lsp_types::Location> = (0..MAX_NORMALIZED_LOCATIONS + 500)
1746            .map(|_| lsp_types::Location {
1747                uri: uri.clone(),
1748                range: lsp_types::Range {
1749                    start: lsp_types::Position {
1750                        line: 0,
1751                        character: 0,
1752                    },
1753                    end: lsp_types::Position {
1754                        line: 0,
1755                        character: 1,
1756                    },
1757                },
1758            })
1759            .collect();
1760
1761        let result = lsp_locations_to_mcp(locs, &ctx).await;
1762
1763        assert_eq!(result.locations.len(), MAX_NORMALIZED_LOCATIONS);
1764        assert!(
1765            result.truncated,
1766            "a response naming more than MAX_NORMALIZED_LOCATIONS must report truncated: true"
1767        );
1768    }
1769}