Skip to main content

mcpls_core/bridge/translator/
call_hierarchy.rs

1//! Call hierarchy prepare/incoming/outgoing handlers.
2
3use lsp_types::{
4    CallHierarchyIncomingCall, CallHierarchyIncomingCallsParams, CallHierarchyItem,
5    CallHierarchyOutgoingCall, CallHierarchyOutgoingCallsParams,
6    CallHierarchyPrepareParams as LspCallHierarchyPrepareParams, PartialResultParams,
7    TextDocumentIdentifier, TextDocumentPositionParams, WorkDoneProgressParams,
8};
9
10use super::Translator;
11use super::dto::{
12    CallHierarchyItemResult, CallHierarchyPrepareResult, IncomingCall, IncomingCallsResult,
13    OutgoingCall, OutgoingCallsResult,
14};
15use super::encoding_ctx::EncodingCtx;
16use super::routing::MAX_POSITION_VALUE;
17use crate::config::ToolKind;
18use crate::error::{Error, Result};
19
20/// Whether a server's capabilities advertise `callHierarchyProvider` support.
21///
22/// Shared by `handle_call_hierarchy_prepare`, `handle_incoming_calls`, and
23/// `handle_outgoing_calls`, which all gate on the same capability field.
24const fn call_hierarchy_provider_supported(caps: &lsp_types::ServerCapabilities) -> bool {
25    matches!(
26        caps.call_hierarchy_provider,
27        Some(
28            lsp_types::CallHierarchyServerCapability::Simple(true)
29                | lsp_types::CallHierarchyServerCapability::Options(_)
30        )
31    )
32}
33
34/// Parsed form of an MCP-facing `CallHierarchyItemResult` JSON value (1-based
35/// coordinates), before its ranges are converted back to the routed server's
36/// negotiated encoding -- which requires resolving that server first (from
37/// [`Self::uri`]), so that step is left to callers via
38/// [`call_hierarchy_item_to_lsp`].
39struct ParsedCallHierarchyItem {
40    uri: lsp_types::Uri,
41    mcp: CallHierarchyItemResult,
42}
43
44/// Deserialize an MCP-facing `CallHierarchyItemResult` JSON value and parse
45/// its URI.
46///
47/// MCP clients receive `CallHierarchyItemResult` from `prepare_call_hierarchy`
48/// and pass it back opaquely to `get_incoming_calls` / `get_outgoing_calls`.
49fn parse_mcp_call_hierarchy_item(item: serde_json::Value) -> Result<ParsedCallHierarchyItem> {
50    let mcp: CallHierarchyItemResult = serde_json::from_value(item)
51        .map_err(|e| Error::InvalidToolParams(format!("Invalid call hierarchy item: {e}")))?;
52
53    let uri = mcp.uri.parse::<lsp_types::Uri>().map_err(|e| {
54        Error::InvalidToolParams(format!("Invalid URI in call hierarchy item: {e}"))
55    })?;
56
57    Ok(ParsedCallHierarchyItem { uri, mcp })
58}
59
60/// Convert a parsed MCP call hierarchy item (1-based coordinates) into a
61/// `lsp_types::CallHierarchyItem` (0-based, in `ctx`'s negotiated encoding).
62async fn call_hierarchy_item_to_lsp(
63    parsed: ParsedCallHierarchyItem,
64    ctx: &EncodingCtx,
65) -> CallHierarchyItem {
66    let ParsedCallHierarchyItem { uri, mcp } = parsed;
67
68    // Round-trip via serde: `convert_call_hierarchy_item` stored the kind as a u32
69    // by serialising `SymbolKind`; we reverse this to reconstruct the same value.
70    let kind: lsp_types::SymbolKind = serde_json::from_value(serde_json::json!(mcp.kind))
71        .unwrap_or(lsp_types::SymbolKind::FUNCTION);
72    let range = ctx.denormalize_range(&uri, &mcp.range).await;
73    let selection_range = ctx.denormalize_range(&uri, &mcp.selection_range).await;
74
75    CallHierarchyItem {
76        name: mcp.name,
77        kind,
78        tags: None,
79        detail: mcp.detail,
80        uri,
81        range,
82        selection_range,
83        data: mcp.data,
84    }
85}
86
87/// Convert LSP call hierarchy item to MCP call hierarchy item.
88async fn convert_call_hierarchy_item(
89    item: CallHierarchyItem,
90    ctx: &EncodingCtx,
91) -> CallHierarchyItemResult {
92    let range = ctx.normalize_range(&item.uri, item.range).await;
93    let selection_range = ctx.normalize_range(&item.uri, item.selection_range).await;
94
95    CallHierarchyItemResult {
96        name: item.name,
97        kind: serde_json::to_value(item.kind)
98            .ok()
99            .and_then(|v| v.as_u64())
100            .and_then(|n| u32::try_from(n).ok())
101            .unwrap_or(0),
102        detail: item.detail,
103        uri: item.uri.to_string(),
104        range,
105        selection_range,
106        data: item.data,
107    }
108}
109
110impl Translator {
111    /// Handle call hierarchy prepare request.
112    ///
113    /// # Errors
114    ///
115    /// Returns an error if the LSP request fails, the file cannot be opened,
116    /// or the routed server does not advertise `callHierarchyProvider` support.
117    pub async fn handle_call_hierarchy_prepare(
118        &self,
119        file_path: String,
120        line: u32,
121        character: u32,
122    ) -> Result<CallHierarchyPrepareResult> {
123        // Validate position bounds
124        if line < 1 || character < 1 {
125            return Err(Error::InvalidToolParams(
126                "Line and character positions must be >= 1".to_string(),
127            ));
128        }
129
130        if line > MAX_POSITION_VALUE || character > MAX_POSITION_VALUE {
131            return Err(Error::InvalidToolParams(format!(
132                "Position values must be <= {MAX_POSITION_VALUE}"
133            )));
134        }
135
136        let (server_id, client, uri) = self
137            .prepare_gated_document(
138                &file_path,
139                ToolKind::CallHierarchy,
140                "callHierarchyProvider",
141                call_hierarchy_provider_supported,
142            )
143            .await?;
144        let ctx = self.encoding_ctx(&server_id);
145        let lsp_position = ctx.to_lsp(&uri, line, character).await;
146
147        let params = LspCallHierarchyPrepareParams {
148            text_document_position_params: TextDocumentPositionParams {
149                text_document: TextDocumentIdentifier { uri },
150                position: lsp_position,
151            },
152            work_done_progress_params: WorkDoneProgressParams::default(),
153        };
154
155        let response: Option<Vec<CallHierarchyItem>> = client
156            .request(
157                "textDocument/prepareCallHierarchy",
158                params,
159                client.request_timeout(),
160            )
161            .await?;
162
163        // Pre-allocate and build result
164        let lsp_items = response.unwrap_or_default();
165        let mut items = Vec::with_capacity(lsp_items.len());
166        for item in lsp_items {
167            items.push(convert_call_hierarchy_item(item, &ctx).await);
168        }
169
170        Ok(CallHierarchyPrepareResult { items })
171    }
172
173    /// Handle incoming calls request.
174    ///
175    /// # Errors
176    ///
177    /// Returns an error if the LSP request fails, the item is invalid, or the
178    /// routed server does not advertise `callHierarchyProvider` support.
179    pub async fn handle_incoming_calls(
180        &self,
181        item: serde_json::Value,
182    ) -> Result<IncomingCallsResult> {
183        // Deserialize as our own type (1-based coords).
184        let parsed = parse_mcp_call_hierarchy_item(item)?;
185
186        // Parse and validate the URI. Resolved with the same ToolKind as
187        // `handle_call_hierarchy_prepare` -- the opaque item this call
188        // receives is only meaningful to the server that produced it, and
189        // that server is guaranteed to be the same one `prepare` synced the
190        // document to since both resolve via the same (language, tool) route.
191        let path = self.parse_file_uri(&parsed.uri)?;
192        let (server_id, client) = self
193            .resolve_client_for_file(&path, ToolKind::CallHierarchy)
194            .await?;
195        self.require_capability(
196            &server_id,
197            "callHierarchyProvider",
198            call_hierarchy_provider_supported,
199        )?;
200        let ctx = self.encoding_ctx(&server_id);
201        let lsp_item = call_hierarchy_item_to_lsp(parsed, &ctx).await;
202
203        let params = CallHierarchyIncomingCallsParams {
204            item: lsp_item,
205            work_done_progress_params: WorkDoneProgressParams::default(),
206            partial_result_params: PartialResultParams::default(),
207        };
208
209        let response: Option<Vec<CallHierarchyIncomingCall>> = client
210            .request(
211                "callHierarchy/incomingCalls",
212                params,
213                client.request_timeout(),
214            )
215            .await?;
216
217        // Pre-allocate and build result
218        let lsp_calls = response.unwrap_or_default();
219        let mut calls = Vec::with_capacity(lsp_calls.len());
220
221        for call in lsp_calls {
222            // Per the LSP spec, `fromRanges` are ranges within the *caller's*
223            // document (`call.from.uri`), not the queried item's document.
224            let from_uri = call.from.uri.clone();
225            let from_ranges = {
226                let mut ranges = Vec::with_capacity(call.from_ranges.len());
227                for range in call.from_ranges {
228                    ranges.push(ctx.normalize_range(&from_uri, range).await);
229                }
230                ranges
231            };
232
233            calls.push(IncomingCall {
234                from: convert_call_hierarchy_item(call.from, &ctx).await,
235                from_ranges,
236            });
237        }
238
239        Ok(IncomingCallsResult { calls })
240    }
241
242    /// Handle outgoing calls request.
243    ///
244    /// # Errors
245    ///
246    /// Returns an error if the LSP request fails, the item is invalid, or the
247    /// routed server does not advertise `callHierarchyProvider` support.
248    pub async fn handle_outgoing_calls(
249        &self,
250        item: serde_json::Value,
251    ) -> Result<OutgoingCallsResult> {
252        // Deserialize as our own type (1-based coords).
253        let parsed = parse_mcp_call_hierarchy_item(item)?;
254
255        // Parse and validate the URI. Same ToolKind/route as `prepare` and
256        // `handle_incoming_calls` -- see that function's comment.
257        let path = self.parse_file_uri(&parsed.uri)?;
258        let (server_id, client) = self
259            .resolve_client_for_file(&path, ToolKind::CallHierarchy)
260            .await?;
261        self.require_capability(
262            &server_id,
263            "callHierarchyProvider",
264            call_hierarchy_provider_supported,
265        )?;
266        let ctx = self.encoding_ctx(&server_id);
267        // Per the LSP spec, an outgoing call's `fromRanges` are ranges within
268        // the *queried* item's own document, not the callee's (`call.to.uri`).
269        let source_uri = parsed.uri.clone();
270        let lsp_item = call_hierarchy_item_to_lsp(parsed, &ctx).await;
271
272        let params = CallHierarchyOutgoingCallsParams {
273            item: lsp_item,
274            work_done_progress_params: WorkDoneProgressParams::default(),
275            partial_result_params: PartialResultParams::default(),
276        };
277
278        let response: Option<Vec<CallHierarchyOutgoingCall>> = client
279            .request(
280                "callHierarchy/outgoingCalls",
281                params,
282                client.request_timeout(),
283            )
284            .await?;
285
286        // Pre-allocate and build result
287        let lsp_calls = response.unwrap_or_default();
288        let mut calls = Vec::with_capacity(lsp_calls.len());
289
290        for call in lsp_calls {
291            let from_ranges = {
292                let mut ranges = Vec::with_capacity(call.from_ranges.len());
293                for range in call.from_ranges {
294                    ranges.push(ctx.normalize_range(&source_uri, range).await);
295                }
296                ranges
297            };
298
299            calls.push(OutgoingCall {
300                to: convert_call_hierarchy_item(call.to, &ctx).await,
301                from_ranges,
302            });
303        }
304
305        Ok(OutgoingCallsResult { calls })
306    }
307}
308
309#[cfg(test)]
310#[allow(clippy::unwrap_used, clippy::expect_used)]
311mod tests {
312    use std::fs;
313    use std::sync::Arc;
314    use std::time::Duration;
315
316    use tempfile::TempDir;
317    use tokio::io::BufReader;
318    use tokio::time::timeout;
319    use url::Url;
320
321    use super::*;
322    use crate::bridge::translator::dto::{Position2D, Range};
323    use crate::bridge::translator::testing::*;
324    use crate::config::ServerId;
325
326    #[tokio::test]
327    async fn test_handle_call_hierarchy_prepare_invalid_position_zero() {
328        let translator = Translator::new();
329        let result = translator
330            .handle_call_hierarchy_prepare("/tmp/test.rs".to_string(), 0, 1)
331            .await;
332        assert!(matches!(result, Err(Error::InvalidToolParams(_))));
333
334        let result = translator
335            .handle_call_hierarchy_prepare("/tmp/test.rs".to_string(), 1, 0)
336            .await;
337        assert!(matches!(result, Err(Error::InvalidToolParams(_))));
338    }
339
340    #[tokio::test]
341    async fn test_handle_call_hierarchy_prepare_invalid_position_too_large() {
342        let translator = Translator::new();
343        let result = translator
344            .handle_call_hierarchy_prepare("/tmp/test.rs".to_string(), 1_000_001, 1)
345            .await;
346        assert!(matches!(result, Err(Error::InvalidToolParams(_))));
347
348        let result = translator
349            .handle_call_hierarchy_prepare("/tmp/test.rs".to_string(), 1, 1_000_001)
350            .await;
351        assert!(matches!(result, Err(Error::InvalidToolParams(_))));
352    }
353
354    #[tokio::test]
355    async fn test_handle_incoming_calls_invalid_json() {
356        let translator = Translator::new();
357        let invalid_item = serde_json::json!({"invalid": "structure"});
358        let result = translator.handle_incoming_calls(invalid_item).await;
359        assert!(matches!(result, Err(Error::InvalidToolParams(_))));
360    }
361
362    #[tokio::test]
363    async fn test_handle_outgoing_calls_invalid_json() {
364        let translator = Translator::new();
365        let invalid_item = serde_json::json!({"invalid": "structure"});
366        let result = translator.handle_outgoing_calls(invalid_item).await;
367        assert!(matches!(result, Err(Error::InvalidToolParams(_))));
368    }
369
370    #[tokio::test]
371    async fn test_convert_call_hierarchy_item_kind_is_numeric() {
372        let item = lsp_types::CallHierarchyItem {
373            name: "my_fn".to_string(),
374            kind: lsp_types::SymbolKind::FUNCTION,
375            tags: None,
376            detail: None,
377            uri: "file:///tmp/test.rs".parse().unwrap(),
378            range: lsp_types::Range {
379                start: lsp_types::Position {
380                    line: 0,
381                    character: 0,
382                },
383                end: lsp_types::Position {
384                    line: 0,
385                    character: 5,
386                },
387            },
388            selection_range: lsp_types::Range {
389                start: lsp_types::Position {
390                    line: 0,
391                    character: 0,
392                },
393                end: lsp_types::Position {
394                    line: 0,
395                    character: 5,
396                },
397            },
398            data: None,
399        };
400        let result = convert_call_hierarchy_item(item, &test_ctx()).await;
401        // SymbolKind::FUNCTION is LSP integer 12
402        assert_eq!(result.kind, 12u32);
403        assert_eq!(result.name, "my_fn");
404    }
405
406    /// Per the LSP spec, an incoming call's `fromRanges` are ranges within
407    /// the *caller's* document (`call.from.uri`), not the queried item's
408    /// document -- `handle_incoming_calls` must convert them against
409    /// `caller.rs`'s own content, not `queried.rs`'s. Uses a UTF-8-negotiated
410    /// server and two files with different multibyte content, so converting
411    /// against the wrong file's line text produces a different, wrong
412    /// answer: `"aöb"` (caller) puts LSP byte offset 3 at UTF-16 column 3
413    /// (`ö` is 2 UTF-8 bytes / 1 UTF-16 unit), while the ASCII `"abc"`
414    /// (queried item) would put the same byte offset at column 4.
415    #[tokio::test]
416    async fn test_handle_incoming_calls_from_ranges_convert_against_callers_own_uri() {
417        let dir = TempDir::new().unwrap();
418        let server_id = ServerId::from("rust");
419        let caps = lsp_types::ServerCapabilities {
420            call_hierarchy_provider: Some(lsp_types::CallHierarchyServerCapability::Simple(true)),
421            ..Default::default()
422        };
423        let (translator, mut server) = translator_with_capabilities_and_encoding(
424            &dir,
425            &server_id,
426            caps,
427            lsp_types::PositionEncodingKind::UTF8,
428        );
429
430        let queried_path = dir.path().join("queried.rs");
431        fs::write(&queried_path, "abc").unwrap();
432        let queried_uri = Url::from_file_path(&queried_path).unwrap().to_string();
433
434        let caller_path = dir.path().join("caller.rs");
435        fs::write(&caller_path, "aöb").unwrap();
436        let caller_uri = Url::from_file_path(&caller_path).unwrap().to_string();
437
438        let item = CallHierarchyItemResult {
439            name: "queried_fn".to_string(),
440            kind: 12,
441            detail: None,
442            uri: queried_uri,
443            range: Range {
444                start: Position2D {
445                    line: 1,
446                    character: 1,
447                },
448                end: Position2D {
449                    line: 1,
450                    character: 4,
451                },
452            },
453            selection_range: Range {
454                start: Position2D {
455                    line: 1,
456                    character: 1,
457                },
458                end: Position2D {
459                    line: 1,
460                    character: 4,
461                },
462            },
463            data: None,
464        };
465
466        let translator = Arc::new(translator);
467        let handle = {
468            let translator = Arc::clone(&translator);
469            let item = serde_json::to_value(item).unwrap();
470            tokio::spawn(async move { translator.handle_incoming_calls(item).await })
471        };
472
473        let mut wire = BufReader::new(&mut server.write_stdout);
474        let request = read_framed_message(&mut wire).await;
475        assert_eq!(request["method"], "callHierarchy/incomingCalls");
476
477        write_response(
478            &mut server.read_half_stdin,
479            &request["id"],
480            serde_json::json!([{
481                "from": {
482                    "name": "caller_fn",
483                    "kind": 12,
484                    "uri": caller_uri,
485                    "range": {
486                        "start": {"line": 0, "character": 0},
487                        "end": {"line": 0, "character": 1}
488                    },
489                    "selectionRange": {
490                        "start": {"line": 0, "character": 0},
491                        "end": {"line": 0, "character": 1}
492                    }
493                },
494                "fromRanges": [{
495                    "start": {"line": 0, "character": 0},
496                    "end": {"line": 0, "character": 3}
497                }]
498            }]),
499        )
500        .await;
501
502        let result = timeout(Duration::from_secs(2), handle)
503            .await
504            .expect("handler call should not hang")
505            .unwrap()
506            .unwrap();
507
508        assert_eq!(result.calls.len(), 1);
509        let from_range = &result.calls[0].from_ranges[0];
510        assert_eq!(
511            from_range.end.character, 3,
512            "fromRanges must convert against the caller's own file (\"aöb\"), not the queried \
513             item's (\"abc\") -- a byte offset of 3 is UTF-16 column 3 in the former, 4 in the \
514             latter"
515        );
516    }
517
518    /// Per the LSP spec, an outgoing call's `fromRanges` are ranges within
519    /// the *queried* item's own document, not the callee's (`call.to.uri`) --
520    /// the inverse directional convention from incoming calls, tested above.
521    #[tokio::test]
522    async fn test_handle_outgoing_calls_from_ranges_convert_against_queried_uri() {
523        let dir = TempDir::new().unwrap();
524        let server_id = ServerId::from("rust");
525        let caps = lsp_types::ServerCapabilities {
526            call_hierarchy_provider: Some(lsp_types::CallHierarchyServerCapability::Simple(true)),
527            ..Default::default()
528        };
529        let (translator, mut server) = translator_with_capabilities_and_encoding(
530            &dir,
531            &server_id,
532            caps,
533            lsp_types::PositionEncodingKind::UTF8,
534        );
535
536        let queried_path = dir.path().join("queried.rs");
537        fs::write(&queried_path, "aöb").unwrap();
538        let queried_uri = Url::from_file_path(&queried_path).unwrap().to_string();
539
540        let callee_path = dir.path().join("callee.rs");
541        fs::write(&callee_path, "abc").unwrap();
542        let callee_uri = Url::from_file_path(&callee_path).unwrap().to_string();
543
544        let item = CallHierarchyItemResult {
545            name: "queried_fn".to_string(),
546            kind: 12,
547            detail: None,
548            uri: queried_uri,
549            range: Range {
550                start: Position2D {
551                    line: 1,
552                    character: 1,
553                },
554                end: Position2D {
555                    line: 1,
556                    character: 4,
557                },
558            },
559            selection_range: Range {
560                start: Position2D {
561                    line: 1,
562                    character: 1,
563                },
564                end: Position2D {
565                    line: 1,
566                    character: 4,
567                },
568            },
569            data: None,
570        };
571
572        let translator = Arc::new(translator);
573        let handle = {
574            let translator = Arc::clone(&translator);
575            let item = serde_json::to_value(item).unwrap();
576            tokio::spawn(async move { translator.handle_outgoing_calls(item).await })
577        };
578
579        let mut wire = BufReader::new(&mut server.write_stdout);
580        let request = read_framed_message(&mut wire).await;
581        assert_eq!(request["method"], "callHierarchy/outgoingCalls");
582
583        write_response(
584            &mut server.read_half_stdin,
585            &request["id"],
586            serde_json::json!([{
587                "to": {
588                    "name": "callee_fn",
589                    "kind": 12,
590                    "uri": callee_uri,
591                    "range": {
592                        "start": {"line": 0, "character": 0},
593                        "end": {"line": 0, "character": 1}
594                    },
595                    "selectionRange": {
596                        "start": {"line": 0, "character": 0},
597                        "end": {"line": 0, "character": 1}
598                    }
599                },
600                "fromRanges": [{
601                    "start": {"line": 0, "character": 0},
602                    "end": {"line": 0, "character": 3}
603                }]
604            }]),
605        )
606        .await;
607
608        let result = timeout(Duration::from_secs(2), handle)
609            .await
610            .expect("handler call should not hang")
611            .unwrap()
612            .unwrap();
613
614        assert_eq!(result.calls.len(), 1);
615        let from_range = &result.calls[0].from_ranges[0];
616        assert_eq!(
617            from_range.end.character, 3,
618            "fromRanges must convert against the queried item's own file (\"aöb\"), not the \
619             callee's (\"abc\") -- a byte offset of 3 is UTF-16 column 3 in the former, 4 in \
620             the latter"
621        );
622    }
623}