Skip to main content

mcpls_core/bridge/translator/
navigation.rs

1//! Hover, go-to-definition/implementation/type-definition, and references
2//! handlers.
3
4use lsp_types::{
5    HoverParams as LspHoverParams, PartialResultParams, ReferenceContext, ReferenceParams,
6    TextDocumentIdentifier, TextDocumentPositionParams, WorkDoneProgressParams,
7};
8
9use super::Translator;
10use super::dto::{
11    DefinitionResult, HoverResult, Location, LocationsResult, Position, ReferencesResult,
12};
13use super::encoding_ctx::EncodingCtx;
14use crate::config::ToolKind;
15use crate::error::Result;
16
17/// Flattens a `Definition` (`Location` or `Location[]`) into an owned `Vec`.
18fn definition_to_locations(definition: lsp_types::Definition) -> Vec<lsp_types::Location> {
19    match definition {
20        lsp_types::Definition::Location(loc) => vec![loc],
21        lsp_types::Definition::LocationList(locs) => locs,
22    }
23}
24
25/// Converts a `DefinitionLink` into a plain `Location` pointing at its target.
26fn definition_link_to_location(link: lsp_types::DefinitionLink) -> lsp_types::Location {
27    lsp_types::Location {
28        uri: link.target_uri,
29        range: link.target_selection_range,
30    }
31}
32
33/// Converts raw LSP locations into MCP-facing `Location` values, normalizing
34/// each range into the caller's 1-based coordinate space.
35async fn lsp_locations_to_mcp(locs: Vec<lsp_types::Location>, ctx: &EncodingCtx) -> Vec<Location> {
36    let mut locations = Vec::with_capacity(locs.len());
37    for loc in locs {
38        locations.push(Location {
39            uri: loc.uri.to_string(),
40            range: ctx.normalize_range(&loc.uri, loc.range).await,
41        });
42    }
43    locations
44}
45
46/// The two response shapes shared by `textDocument/definition`,
47/// `textDocument/implementation`, and `textDocument/typeDefinition`: either a
48/// single `Definition` (`Location` or `Location[]`), or a `DefinitionLink[]`
49/// from clients that opted into `LinkSupport`.
50enum GotoKind {
51    /// A plain `Definition`, as returned to clients without `LinkSupport`.
52    Definition(lsp_types::Definition),
53    /// A `DefinitionLink[]`, as returned to clients with `LinkSupport`.
54    DefinitionLinkList(Vec<lsp_types::DefinitionLink>),
55}
56
57/// Implemented once per go-to-X response enum so [`goto_response_to_locations`]
58/// can normalize all three through one code path instead of three near-identical
59/// match arms.
60trait GotoResponse {
61    /// Reduce the response enum down to the two variants shared by every
62    /// go-to-X LSP response.
63    fn into_kind(self) -> GotoKind;
64}
65
66impl GotoResponse for lsp_types::DefinitionResponse {
67    fn into_kind(self) -> GotoKind {
68        match self {
69            Self::Definition(def) => GotoKind::Definition(def),
70            Self::DefinitionLinkList(links) => GotoKind::DefinitionLinkList(links),
71        }
72    }
73}
74
75impl GotoResponse for lsp_types::ImplementationResponse {
76    fn into_kind(self) -> GotoKind {
77        match self {
78            Self::Definition(def) => GotoKind::Definition(def),
79            Self::DefinitionLinkList(links) => GotoKind::DefinitionLinkList(links),
80        }
81    }
82}
83
84impl GotoResponse for lsp_types::TypeDefinitionResponse {
85    fn into_kind(self) -> GotoKind {
86        match self {
87            Self::Definition(def) => GotoKind::Definition(def),
88            Self::DefinitionLinkList(links) => GotoKind::DefinitionLinkList(links),
89        }
90    }
91}
92
93/// Normalize a go-to-X response (`textDocument/definition`,
94/// `textDocument/implementation`, or `textDocument/typeDefinition`) into a
95/// flat list of MCP `Location` values.
96async fn goto_response_to_locations<R: GotoResponse>(
97    response: Option<R>,
98    ctx: &EncodingCtx,
99) -> Vec<Location> {
100    let lsp_locs = match response.map(GotoResponse::into_kind) {
101        Some(GotoKind::Definition(def)) => definition_to_locations(def),
102        Some(GotoKind::DefinitionLinkList(links)) => {
103            links.into_iter().map(definition_link_to_location).collect()
104        }
105        None => vec![],
106    };
107    lsp_locations_to_mcp(lsp_locs, ctx).await
108}
109
110/// Implemented once per go-to-X request params type so [`Translator::handle_goto`]
111/// can build request params generically instead of duplicating the
112/// `TextDocumentPositionParams` wiring per handler.
113trait GotoParams: Sized {
114    /// Build the request params from the resolved document position, filling
115    /// the remaining fields (work-done/partial-result progress) with defaults.
116    fn from_position(text_document_position_params: TextDocumentPositionParams) -> Self;
117}
118
119impl GotoParams for lsp_types::DefinitionParams {
120    fn from_position(text_document_position_params: TextDocumentPositionParams) -> Self {
121        Self {
122            text_document_position_params,
123            work_done_progress_params: WorkDoneProgressParams::default(),
124            partial_result_params: PartialResultParams::default(),
125        }
126    }
127}
128
129impl GotoParams for lsp_types::ImplementationParams {
130    fn from_position(text_document_position_params: TextDocumentPositionParams) -> Self {
131        Self {
132            text_document_position_params,
133            work_done_progress_params: WorkDoneProgressParams::default(),
134            partial_result_params: PartialResultParams::default(),
135        }
136    }
137}
138
139impl GotoParams for lsp_types::TypeDefinitionParams {
140    fn from_position(text_document_position_params: TextDocumentPositionParams) -> Self {
141        Self {
142            text_document_position_params,
143            work_done_progress_params: WorkDoneProgressParams::default(),
144            partial_result_params: PartialResultParams::default(),
145        }
146    }
147}
148
149/// Extracts hover contents as a plain string.
150///
151/// `MarkedString` is `#[deprecated]` in favor of `MarkupContent`, but LSP
152/// 3.17 servers may still send it inside `Hover.contents` -- dropping
153/// support would silently discard hover text from those servers, so this
154/// (and `marked_string_to_string`) carry a narrow, scoped allow rather than
155/// rewriting to `MarkupContent`-only.
156#[allow(deprecated)]
157fn extract_hover_contents(contents: lsp_types::Contents) -> String {
158    match contents {
159        lsp_types::Contents::MarkedString(marked_string) => marked_string_to_string(marked_string),
160        lsp_types::Contents::MarkedStringList(marked_strings) => marked_strings
161            .into_iter()
162            .map(marked_string_to_string)
163            .collect::<Vec<_>>()
164            .join("\n\n"),
165        lsp_types::Contents::MarkupContent(markup) => markup.value,
166    }
167}
168
169/// Convert a marked string to a plain string.
170#[allow(deprecated)]
171fn marked_string_to_string(marked: lsp_types::MarkedString) -> String {
172    match marked {
173        lsp_types::MarkedString::String(s) => s,
174        lsp_types::MarkedString::MarkedStringWithLanguage(ls) => {
175            format!("```{}\n{}\n```", ls.language, ls.value)
176        }
177    }
178}
179
180impl Translator {
181    /// Handle hover request.
182    ///
183    /// # Errors
184    ///
185    /// Returns an error if the LSP request fails, the file cannot be opened,
186    /// or the routed server does not advertise `hoverProvider` support.
187    pub async fn handle_hover(&self, file_path: String, position: Position) -> Result<HoverResult> {
188        let Position { line, character } = position;
189        let (server_id, client, uri) = self
190            .prepare_gated_document(&file_path, ToolKind::Hover, "hoverProvider", |caps| {
191                matches!(
192                    caps.hover_provider,
193                    Some(
194                        lsp_types::HoverProvider::Bool(true)
195                            | lsp_types::HoverProvider::HoverOptions(_)
196                    )
197                )
198            })
199            .await?;
200        let ctx = self.encoding_ctx(&server_id);
201        let lsp_position = ctx.to_lsp(&uri, line, character).await;
202        let response_uri = uri.clone();
203
204        let params = LspHoverParams {
205            text_document_position_params: TextDocumentPositionParams {
206                text_document: TextDocumentIdentifier { uri },
207                position: lsp_position,
208            },
209            work_done_progress_params: WorkDoneProgressParams::default(),
210        };
211
212        let response = client
213            .request_typed::<lsp_types::HoverRequest>(params, client.request_timeout())
214            .await?;
215
216        let result = match response {
217            Some(hover) => {
218                let contents = extract_hover_contents(hover.contents);
219                let range = match hover.range {
220                    Some(r) => Some(ctx.normalize_range(&response_uri, r).await),
221                    None => None,
222                };
223                HoverResult { contents, range }
224            }
225            None => HoverResult {
226                contents: "No hover information available".to_string(),
227                range: None,
228            },
229        };
230
231        Ok(result)
232    }
233
234    /// Shared implementation of the go-to-X handlers (`textDocument/definition`,
235    /// `textDocument/implementation`, `textDocument/typeDefinition`): gate on
236    /// the request's capability, translate the MCP position into LSP
237    /// coordinates, dispatch the LSP request, and flatten the response into
238    /// MCP locations. Each public handler supplies its request type via `R`
239    /// plus the capability key/predicate specific to it.
240    ///
241    /// # Errors
242    ///
243    /// Returns an error if the LSP request fails, the file cannot be opened,
244    /// or the routed server does not advertise `capability` support.
245    async fn handle_goto<R, T>(
246        &self,
247        file_path: &str,
248        position: Position,
249        tool: ToolKind,
250        capability: &'static str,
251        supported: impl FnOnce(&lsp_types::ServerCapabilities) -> bool,
252    ) -> Result<Vec<Location>>
253    where
254        R: lsp_types::Request<Result = Option<T>>,
255        R::Params: GotoParams,
256        T: GotoResponse,
257    {
258        let Position { line, character } = position;
259        let (server_id, client, uri) = self
260            .prepare_gated_document(file_path, tool, capability, supported)
261            .await?;
262        let ctx = self.encoding_ctx(&server_id);
263        let lsp_position = ctx.to_lsp(&uri, line, character).await;
264
265        let params = R::Params::from_position(TextDocumentPositionParams {
266            text_document: TextDocumentIdentifier { uri },
267            position: lsp_position,
268        });
269
270        let response = client
271            .request_typed::<R>(params, client.request_timeout())
272            .await?;
273
274        Ok(goto_response_to_locations(response, &ctx).await)
275    }
276
277    /// Handle definition request.
278    ///
279    /// # Errors
280    ///
281    /// Returns an error if the LSP request fails, the file cannot be opened,
282    /// or the routed server does not advertise `definitionProvider` support.
283    pub async fn handle_definition(
284        &self,
285        file_path: String,
286        position: Position,
287    ) -> Result<DefinitionResult> {
288        let locations = self
289            .handle_goto::<lsp_types::DefinitionRequest, _>(
290                &file_path,
291                position,
292                ToolKind::Definition,
293                "definitionProvider",
294                |caps| {
295                    matches!(
296                        caps.definition_provider,
297                        Some(
298                            lsp_types::DefinitionProvider::Bool(true)
299                                | lsp_types::DefinitionProvider::DefinitionOptions(_)
300                        )
301                    )
302                },
303            )
304            .await?;
305
306        Ok(DefinitionResult { locations })
307    }
308
309    /// Handle references request.
310    ///
311    /// # Errors
312    ///
313    /// Returns an error if the LSP request fails, the file cannot be opened,
314    /// or the routed server does not advertise `referencesProvider` support.
315    pub async fn handle_references(
316        &self,
317        file_path: String,
318        position: Position,
319        include_declaration: bool,
320    ) -> Result<ReferencesResult> {
321        let Position { line, character } = position;
322        let (server_id, client, uri) = self
323            .prepare_gated_document(
324                &file_path,
325                ToolKind::References,
326                "referencesProvider",
327                |caps| {
328                    matches!(
329                        caps.references_provider,
330                        Some(
331                            lsp_types::ReferencesProvider::Bool(true)
332                                | lsp_types::ReferencesProvider::ReferenceOptions(_)
333                        )
334                    )
335                },
336            )
337            .await?;
338        let ctx = self.encoding_ctx(&server_id);
339        let lsp_position = ctx.to_lsp(&uri, line, character).await;
340
341        let params = ReferenceParams {
342            text_document_position_params: TextDocumentPositionParams {
343                text_document: TextDocumentIdentifier { uri },
344                position: lsp_position,
345            },
346            work_done_progress_params: WorkDoneProgressParams::default(),
347            partial_result_params: PartialResultParams::default(),
348            context: ReferenceContext {
349                include_declaration,
350            },
351        };
352
353        let response = client
354            .request_typed::<lsp_types::ReferencesRequest>(params, client.request_timeout())
355            .await?;
356
357        let locations = response.unwrap_or_default();
358
359        let mut result_locations = Vec::with_capacity(locations.len());
360        for loc in locations {
361            result_locations.push(Location {
362                uri: loc.uri.to_string(),
363                range: ctx.normalize_range(&loc.uri, loc.range).await,
364            });
365        }
366        let result = ReferencesResult {
367            locations: result_locations,
368        };
369
370        Ok(result)
371    }
372
373    /// Handle go-to-implementation request (`textDocument/implementation`).
374    ///
375    /// Returns the locations of trait method or interface member implementations.
376    ///
377    /// # Errors
378    ///
379    /// Returns an error if the LSP request fails, the file cannot be opened,
380    /// or the routed server does not advertise `implementationProvider` support.
381    pub async fn handle_implementation(
382        &self,
383        file_path: String,
384        position: Position,
385    ) -> Result<LocationsResult> {
386        let locations = self
387            .handle_goto::<lsp_types::ImplementationRequest, _>(
388                &file_path,
389                position,
390                ToolKind::Implementation,
391                "implementationProvider",
392                |caps| {
393                    matches!(
394                        caps.implementation_provider,
395                        Some(
396                            lsp_types::ImplementationProvider::Bool(true)
397                                | lsp_types::ImplementationProvider::ImplementationOptions(_)
398                                | lsp_types::ImplementationProvider::ImplementationRegistrationOptions(_)
399                        )
400                    )
401                },
402            )
403            .await?;
404
405        Ok(LocationsResult { locations })
406    }
407
408    /// Handle go-to-type-definition request (`textDocument/typeDefinition`).
409    ///
410    /// Returns the type definition location of the expression at position. Distinct
411    /// from go-to-definition for variable bindings where definition and type differ.
412    ///
413    /// # Errors
414    ///
415    /// Returns an error if the LSP request fails, the file cannot be opened,
416    /// or the routed server does not advertise `typeDefinitionProvider` support.
417    pub async fn handle_type_definition(
418        &self,
419        file_path: String,
420        position: Position,
421    ) -> Result<LocationsResult> {
422        let locations = self
423            .handle_goto::<lsp_types::TypeDefinitionRequest, _>(
424                &file_path,
425                position,
426                ToolKind::TypeDefinition,
427                "typeDefinitionProvider",
428                |caps| {
429                    matches!(
430                        caps.type_definition_provider,
431                        Some(
432                            lsp_types::TypeDefinitionProvider::Bool(true)
433                                | lsp_types::TypeDefinitionProvider::TypeDefinitionOptions(_)
434                                | lsp_types::TypeDefinitionProvider::TypeDefinitionRegistrationOptions(_)
435                        )
436                    )
437                },
438            )
439            .await?;
440
441        Ok(LocationsResult { locations })
442    }
443}
444
445#[cfg(test)]
446#[allow(clippy::unwrap_used, clippy::expect_used, deprecated)]
447mod tests {
448    use std::fs;
449    use std::sync::Arc;
450    use std::time::Duration;
451
452    use tempfile::TempDir;
453    use tokio::io::BufReader;
454    use tokio::time::timeout;
455    use url::Url;
456
457    use super::*;
458    use crate::bridge::translator::testing::*;
459    use crate::config::ServerId;
460
461    #[test]
462    fn test_extract_hover_contents_string() {
463        let marked_string = lsp_types::MarkedString::String("Test hover".to_string());
464        let contents = lsp_types::Contents::MarkedString(marked_string);
465        let result = extract_hover_contents(contents);
466        assert_eq!(result, "Test hover");
467    }
468
469    #[test]
470    fn test_extract_hover_contents_language_string() {
471        let marked_string = lsp_types::MarkedString::MarkedStringWithLanguage(
472            lsp_types::MarkedStringWithLanguage {
473                language: "rust".to_string(),
474                value: "fn main() {}".to_string(),
475            },
476        );
477        let contents = lsp_types::Contents::MarkedString(marked_string);
478        let result = extract_hover_contents(contents);
479        assert_eq!(result, "```rust\nfn main() {}\n```");
480    }
481
482    #[test]
483    fn test_extract_hover_contents_markup() {
484        let markup = lsp_types::MarkupContent {
485            kind: lsp_types::MarkupKind::Markdown,
486            value: "# Documentation".to_string(),
487        };
488        let contents = lsp_types::Contents::MarkupContent(markup);
489        let result = extract_hover_contents(contents);
490        assert_eq!(result, "# Documentation");
491    }
492
493    /// Success-path coverage for `handle_definition` through the
494    /// `Definition::Location` -> `GotoKind::Definition` arm, pinning the
495    /// `GotoResponse` impl for `DefinitionResponse`.
496    #[tokio::test]
497    async fn test_handle_definition_flattens_single_location() {
498        let dir = TempDir::new().unwrap();
499        let server_id = ServerId::from("rust");
500        let caps = lsp_types::ServerCapabilities {
501            definition_provider: Some(lsp_types::DefinitionProvider::Bool(true)),
502            ..Default::default()
503        };
504        let (translator, mut server) = translator_with_capabilities(&dir, &server_id, caps);
505
506        let path = dir.path().join("main.rs");
507        fs::write(&path, "fn main() {}").unwrap();
508        let target_path = dir.path().join("target.rs");
509        fs::write(&target_path, "fn target() {}").unwrap();
510        let target_uri = Url::from_file_path(&target_path).unwrap().to_string();
511
512        let translator = Arc::new(translator);
513        let handle = {
514            let translator = Arc::clone(&translator);
515            let path = path.to_string_lossy().to_string();
516            tokio::spawn(async move {
517                translator
518                    .handle_definition(
519                        path,
520                        Position {
521                            line: 1,
522                            character: 1,
523                        },
524                    )
525                    .await
526            })
527        };
528
529        let mut wire = BufReader::new(&mut server.write_stdout);
530        let opened = read_framed_message(&mut wire).await;
531        assert_eq!(opened["method"], "textDocument/didOpen");
532        let request = read_framed_message(&mut wire).await;
533        assert_eq!(request["method"], "textDocument/definition");
534
535        write_response(
536            &mut server.read_half_stdin,
537            &request["id"],
538            serde_json::json!({
539                "uri": target_uri,
540                "range": {
541                    "start": {"line": 0, "character": 0},
542                    "end": {"line": 0, "character": 6}
543                }
544            }),
545        )
546        .await;
547
548        let result = timeout(Duration::from_secs(2), handle)
549            .await
550            .expect("handle_definition should not hang")
551            .unwrap()
552            .unwrap();
553
554        assert_eq!(result.locations.len(), 1);
555        assert_eq!(result.locations[0].uri, target_uri);
556    }
557
558    /// Success-path coverage for `handle_implementation` through the
559    /// `Definition::LocationList` -> `GotoKind::Definition` arm, pinning the
560    /// `GotoResponse` impl for `ImplementationResponse`.
561    #[tokio::test]
562    async fn test_handle_implementation_flattens_location_list() {
563        let dir = TempDir::new().unwrap();
564        let server_id = ServerId::from("rust");
565        let caps = lsp_types::ServerCapabilities {
566            implementation_provider: Some(lsp_types::ImplementationProvider::Bool(true)),
567            ..Default::default()
568        };
569        let (translator, mut server) = translator_with_capabilities(&dir, &server_id, caps);
570
571        let path = dir.path().join("main.rs");
572        fs::write(&path, "fn main() {}").unwrap();
573        let first_impl_path = dir.path().join("impl_a.rs");
574        fs::write(&first_impl_path, "struct A;").unwrap();
575        let first_impl_uri = Url::from_file_path(&first_impl_path).unwrap().to_string();
576        let second_impl_path = dir.path().join("impl_b.rs");
577        fs::write(&second_impl_path, "struct B;").unwrap();
578        let second_impl_uri = Url::from_file_path(&second_impl_path).unwrap().to_string();
579
580        let translator = Arc::new(translator);
581        let handle = {
582            let translator = Arc::clone(&translator);
583            let path = path.to_string_lossy().to_string();
584            tokio::spawn(async move {
585                translator
586                    .handle_implementation(
587                        path,
588                        Position {
589                            line: 1,
590                            character: 1,
591                        },
592                    )
593                    .await
594            })
595        };
596
597        let mut wire = BufReader::new(&mut server.write_stdout);
598        let opened = read_framed_message(&mut wire).await;
599        assert_eq!(opened["method"], "textDocument/didOpen");
600        let request = read_framed_message(&mut wire).await;
601        assert_eq!(request["method"], "textDocument/implementation");
602
603        write_response(
604            &mut server.read_half_stdin,
605            &request["id"],
606            serde_json::json!([
607                {
608                    "uri": first_impl_uri,
609                    "range": {
610                        "start": {"line": 0, "character": 0},
611                        "end": {"line": 0, "character": 9}
612                    }
613                },
614                {
615                    "uri": second_impl_uri,
616                    "range": {
617                        "start": {"line": 0, "character": 0},
618                        "end": {"line": 0, "character": 9}
619                    }
620                }
621            ]),
622        )
623        .await;
624
625        let result = timeout(Duration::from_secs(2), handle)
626            .await
627            .expect("handle_implementation should not hang")
628            .unwrap()
629            .unwrap();
630
631        assert_eq!(result.locations.len(), 2);
632        assert_eq!(result.locations[0].uri, first_impl_uri);
633        assert_eq!(result.locations[1].uri, second_impl_uri);
634    }
635
636    /// Success-path coverage for `handle_type_definition` through the
637    /// `DefinitionLinkList` -> `GotoKind::DefinitionLinkList` arm, pinning
638    /// the `GotoResponse` impl for `TypeDefinitionResponse` and the
639    /// `definition_link_to_location` mapping (`target_selection_range`, not
640    /// `target_range`).
641    #[tokio::test]
642    async fn test_handle_type_definition_flattens_definition_link_list() {
643        let dir = TempDir::new().unwrap();
644        let server_id = ServerId::from("rust");
645        let caps = lsp_types::ServerCapabilities {
646            type_definition_provider: Some(lsp_types::TypeDefinitionProvider::Bool(true)),
647            ..Default::default()
648        };
649        let (translator, mut server) = translator_with_capabilities(&dir, &server_id, caps);
650
651        let path = dir.path().join("main.rs");
652        fs::write(&path, "fn main() {}").unwrap();
653        let target_path = dir.path().join("target_type.rs");
654        fs::write(&target_path, "struct TargetType;").unwrap();
655        let target_uri = Url::from_file_path(&target_path).unwrap().to_string();
656
657        let translator = Arc::new(translator);
658        let handle = {
659            let translator = Arc::clone(&translator);
660            let path = path.to_string_lossy().to_string();
661            tokio::spawn(async move {
662                translator
663                    .handle_type_definition(
664                        path,
665                        Position {
666                            line: 1,
667                            character: 1,
668                        },
669                    )
670                    .await
671            })
672        };
673
674        let mut wire = BufReader::new(&mut server.write_stdout);
675        let opened = read_framed_message(&mut wire).await;
676        assert_eq!(opened["method"], "textDocument/didOpen");
677        let request = read_framed_message(&mut wire).await;
678        assert_eq!(request["method"], "textDocument/typeDefinition");
679
680        write_response(
681            &mut server.read_half_stdin,
682            &request["id"],
683            serde_json::json!([{
684                "targetUri": target_uri,
685                "targetRange": {
686                    "start": {"line": 0, "character": 0},
687                    "end": {"line": 0, "character": 18}
688                },
689                "targetSelectionRange": {
690                    "start": {"line": 0, "character": 7},
691                    "end": {"line": 0, "character": 17}
692                }
693            }]),
694        )
695        .await;
696
697        let result = timeout(Duration::from_secs(2), handle)
698            .await
699            .expect("handle_type_definition should not hang")
700            .unwrap()
701            .unwrap();
702
703        assert_eq!(result.locations.len(), 1);
704        assert_eq!(result.locations[0].uri, target_uri);
705        assert_eq!(result.locations[0].range.start.character, 8);
706    }
707}