Skip to main content

mcpls_core/bridge/translator/
call_hierarchy.rs

1//! Call hierarchy prepare/incoming/outgoing handlers.
2
3use lsp_types::{
4    CallHierarchyIncomingCallsParams, CallHierarchyItem, CallHierarchyOutgoingCallsParams,
5    CallHierarchyPrepareParams as LspCallHierarchyPrepareParams, PartialResultParams,
6    TextDocumentIdentifier, TextDocumentPositionParams, WorkDoneProgressParams,
7};
8
9use super::Translator;
10use super::dto::{
11    CallHierarchyItemResult, CallHierarchyPrepareResult, IncomingCall, IncomingCallsResult,
12    OutgoingCall, OutgoingCallsResult, Position, lsp_kind_to_u32,
13};
14use super::encoding_ctx::EncodingCtx;
15use super::routing::{Capability, IndexingGate, MAX_POSITION_VALUE};
16use crate::config::ToolKind;
17use crate::error::{Error, Result};
18
19/// Parsed form of an MCP-facing `CallHierarchyItemResult` JSON value (1-based
20/// coordinates), before its ranges are converted back to the routed server's
21/// negotiated encoding -- which requires resolving that server first (from
22/// [`Self::uri`]), so that step is left to callers via
23/// [`call_hierarchy_item_to_lsp`].
24struct ParsedCallHierarchyItem {
25    uri: lsp_types::Uri,
26    mcp: CallHierarchyItemResult,
27}
28
29/// Deserialize an MCP-facing `CallHierarchyItemResult` JSON value and parse
30/// its URI.
31///
32/// MCP clients receive `CallHierarchyItemResult` from `prepare_call_hierarchy`
33/// and pass it back opaquely to `get_incoming_calls` / `get_outgoing_calls`.
34fn parse_mcp_call_hierarchy_item(item: serde_json::Value) -> Result<ParsedCallHierarchyItem> {
35    let mcp: CallHierarchyItemResult = serde_json::from_value(item)
36        .map_err(|e| Error::InvalidToolParams(format!("Invalid call hierarchy item: {e}")))?;
37
38    // `gen-lsp-types`'s `Uri` is an opaque string wrapper with no validating
39    // parse, so constructing it is infallible -- the malformed-URI rejection
40    // this call used to provide is gone. Downstream consumers (e.g.
41    // `parse_file_uri`) still validate the `file://` scheme and reject what
42    // they can't use.
43    let uri = lsp_types::Uri::from(mcp.uri.as_str());
44
45    Ok(ParsedCallHierarchyItem { uri, mcp })
46}
47
48/// Convert a parsed MCP call hierarchy item (1-based coordinates) into a
49/// `lsp_types::CallHierarchyItem` (0-based, in `ctx`'s negotiated encoding).
50async fn call_hierarchy_item_to_lsp(
51    parsed: ParsedCallHierarchyItem,
52    ctx: &EncodingCtx,
53) -> CallHierarchyItem {
54    let ParsedCallHierarchyItem { uri, mcp } = parsed;
55
56    // `SymbolKind: From<u32>` is infallible (see `lsp_kind_to_u32`'s docs),
57    // so this exactly reverses the `lsp_kind_to_u32` call in
58    // `convert_call_hierarchy_item` with no fallback needed.
59    let kind = lsp_types::SymbolKind::from(mcp.kind);
60    let range = ctx.denormalize_range(&uri, &mcp.range).await;
61    let selection_range = ctx.denormalize_range(&uri, &mcp.selection_range).await;
62
63    CallHierarchyItem {
64        name: mcp.name,
65        kind,
66        tags: None,
67        detail: mcp.detail,
68        uri,
69        range,
70        selection_range,
71        data: mcp.data,
72    }
73}
74
75/// Convert LSP call hierarchy item to MCP call hierarchy item.
76async fn convert_call_hierarchy_item(
77    item: CallHierarchyItem,
78    ctx: &EncodingCtx,
79) -> CallHierarchyItemResult {
80    let out_of_workspace = ctx.is_out_of_workspace(&item.uri);
81    let range = ctx.normalize_range(&item.uri, item.range).await;
82    let selection_range = ctx.normalize_range(&item.uri, item.selection_range).await;
83
84    CallHierarchyItemResult {
85        name: item.name,
86        kind: lsp_kind_to_u32(item.kind),
87        detail: item.detail,
88        uri: item.uri.to_string(),
89        range,
90        selection_range,
91        data: item.data,
92        out_of_workspace,
93    }
94}
95
96impl Translator {
97    /// Handle call hierarchy prepare request.
98    ///
99    /// # Errors
100    ///
101    /// Returns an error if the LSP request fails, the file cannot be opened,
102    /// or the routed server does not advertise `callHierarchyProvider` support.
103    pub async fn handle_call_hierarchy_prepare(
104        &self,
105        file_path: String,
106        position: Position,
107    ) -> Result<CallHierarchyPrepareResult> {
108        let Position { line, character } = position;
109        // Validate position bounds
110        if line < 1 || character < 1 {
111            return Err(Error::InvalidToolParams(
112                "Line and character positions must be >= 1".to_string(),
113            ));
114        }
115
116        if line > MAX_POSITION_VALUE || character > MAX_POSITION_VALUE {
117            return Err(Error::InvalidToolParams(format!(
118                "Position values must be <= {MAX_POSITION_VALUE}"
119            )));
120        }
121
122        let (server_id, client, uri) = self
123            .prepare_gated_document(
124                &file_path,
125                ToolKind::CallHierarchy,
126                Capability::CallHierarchy,
127                IndexingGate::NotRequired,
128            )
129            .await?;
130        let ctx = self.encoding_ctx(&server_id);
131        let lsp_position = ctx.to_lsp(&uri, line, character).await;
132
133        let params = LspCallHierarchyPrepareParams {
134            text_document_position_params: TextDocumentPositionParams {
135                text_document: TextDocumentIdentifier { uri },
136                position: lsp_position,
137            },
138            work_done_progress_params: WorkDoneProgressParams::default(),
139        };
140
141        let response = client
142            .request_typed::<lsp_types::CallHierarchyPrepareRequest>(
143                params,
144                client.request_timeout(),
145            )
146            .await?;
147
148        // Pre-allocate and build result. Not filtered to workspace roots --
149        // see `lsp_locations_to_mcp`'s doc comment in `navigation.rs` for why
150        // a read-only location outside the workspace (stdlib, a dependency)
151        // is normal, expected navigation rather than something to drop.
152        let lsp_items = response.unwrap_or_default();
153        let mut items = Vec::with_capacity(lsp_items.len());
154        for item in lsp_items {
155            items.push(convert_call_hierarchy_item(item, &ctx).await);
156        }
157
158        Ok(CallHierarchyPrepareResult {
159            items,
160            positions_degraded: ctx.positions_degraded(),
161        })
162    }
163
164    /// Handle incoming calls request.
165    ///
166    /// Routing through `prepare_gated_document_for_path` means this now
167    /// stats, reads, and `didOpen`s the item's own file as a side effect,
168    /// even though a call-hierarchy item is opaque per the LSP spec and
169    /// needs no open document -- accepted for chokepoint/gating
170    /// consistency with `handle_references` and the other whole-workspace
171    /// tools; it does mean a replayed item whose file has since been
172    /// deleted now fails on that stat instead of just proceeding.
173    ///
174    /// # Errors
175    ///
176    /// Returns an error if the LSP request fails, the item is invalid, the
177    /// routed server does not advertise `callHierarchyProvider` support, or
178    /// the server is still indexing the workspace after
179    /// `INDEXING_READY_TIMEOUT`.
180    pub async fn handle_incoming_calls(
181        &self,
182        item: serde_json::Value,
183    ) -> Result<IncomingCallsResult> {
184        // Deserialize as our own type (1-based coords).
185        let parsed = parse_mcp_call_hierarchy_item(item)?;
186
187        // Same ToolKind/route as `handle_call_hierarchy_prepare`.
188        let path = self.parse_file_uri(&parsed.uri)?;
189        let (server_id, client, _uri) = self
190            .prepare_gated_document_for_path(
191                &path,
192                ToolKind::CallHierarchy,
193                Capability::CallHierarchy,
194                IndexingGate::Required,
195            )
196            .await?;
197        let ctx = self.encoding_ctx(&server_id);
198        let lsp_item = call_hierarchy_item_to_lsp(parsed, &ctx).await;
199
200        let params = CallHierarchyIncomingCallsParams {
201            item: lsp_item,
202            work_done_progress_params: WorkDoneProgressParams::default(),
203            partial_result_params: PartialResultParams::default(),
204        };
205
206        let response = client
207            .request_typed::<lsp_types::CallHierarchyIncomingCallsRequest>(
208                params,
209                client.request_timeout(),
210            )
211            .await?;
212
213        // Pre-allocate and build result. Not filtered to workspace roots --
214        // see `handle_call_hierarchy_prepare`'s comment above.
215        let lsp_calls = response.unwrap_or_default();
216        let mut calls = Vec::with_capacity(lsp_calls.len());
217
218        for call in lsp_calls {
219            // Per the LSP spec, `fromRanges` are ranges within the *caller's*
220            // document (`call.from.uri`), not the queried item's document.
221            let from_uri = call.from.uri.clone();
222            let from_ranges = {
223                let mut ranges = Vec::with_capacity(call.from_ranges.len());
224                for range in call.from_ranges {
225                    ranges.push(ctx.normalize_range(&from_uri, range).await);
226                }
227                ranges
228            };
229
230            calls.push(IncomingCall {
231                from: convert_call_hierarchy_item(call.from, &ctx).await,
232                from_ranges,
233            });
234        }
235
236        Ok(IncomingCallsResult {
237            calls,
238            positions_degraded: ctx.positions_degraded(),
239        })
240    }
241
242    /// Handle outgoing calls request.
243    ///
244    /// Same `didOpen`-as-side-effect trade-off as `handle_incoming_calls` --
245    /// see that method's doc.
246    ///
247    /// # Errors
248    ///
249    /// Returns an error if the LSP request fails, the item is invalid, the
250    /// routed server does not advertise `callHierarchyProvider` support, or
251    /// the server is still indexing the workspace after
252    /// `INDEXING_READY_TIMEOUT`.
253    pub async fn handle_outgoing_calls(
254        &self,
255        item: serde_json::Value,
256    ) -> Result<OutgoingCallsResult> {
257        // Deserialize as our own type (1-based coords).
258        let parsed = parse_mcp_call_hierarchy_item(item)?;
259
260        // Parse the URI and gate through the same chokepoint as
261        // `handle_incoming_calls` -- see that function's comment (#423).
262        // Same ToolKind/route as `prepare`.
263        let path = self.parse_file_uri(&parsed.uri)?;
264        let (server_id, client, _uri) = self
265            .prepare_gated_document_for_path(
266                &path,
267                ToolKind::CallHierarchy,
268                Capability::CallHierarchy,
269                IndexingGate::Required,
270            )
271            .await?;
272        let ctx = self.encoding_ctx(&server_id);
273        // Per the LSP spec, an outgoing call's `fromRanges` are ranges within
274        // the *queried* item's own document, not the callee's (`call.to.uri`).
275        let source_uri = parsed.uri.clone();
276        let lsp_item = call_hierarchy_item_to_lsp(parsed, &ctx).await;
277
278        let params = CallHierarchyOutgoingCallsParams {
279            item: lsp_item,
280            work_done_progress_params: WorkDoneProgressParams::default(),
281            partial_result_params: PartialResultParams::default(),
282        };
283
284        let response = client
285            .request_typed::<lsp_types::CallHierarchyOutgoingCallsRequest>(
286                params,
287                client.request_timeout(),
288            )
289            .await?;
290
291        // Pre-allocate and build result. Not filtered to workspace roots --
292        // see `handle_call_hierarchy_prepare`'s comment above.
293        let lsp_calls = response.unwrap_or_default();
294        let mut calls = Vec::with_capacity(lsp_calls.len());
295
296        for call in lsp_calls {
297            let from_ranges = {
298                let mut ranges = Vec::with_capacity(call.from_ranges.len());
299                for range in call.from_ranges {
300                    ranges.push(ctx.normalize_range(&source_uri, range).await);
301                }
302                ranges
303            };
304
305            calls.push(OutgoingCall {
306                to: convert_call_hierarchy_item(call.to, &ctx).await,
307                from_ranges,
308            });
309        }
310
311        Ok(OutgoingCallsResult {
312            calls,
313            positions_degraded: ctx.positions_degraded(),
314        })
315    }
316}
317
318#[cfg(test)]
319#[allow(clippy::unwrap_used, clippy::expect_used)]
320mod tests {
321    use std::fs;
322    use std::sync::Arc;
323    use std::time::Duration;
324
325    use tempfile::TempDir;
326    use tokio::io::BufReader;
327    use tokio::sync::Mutex;
328    use tokio::time::timeout;
329    use url::Url;
330
331    use super::*;
332    use crate::bridge::NotificationCache;
333    use crate::bridge::translator::dto::{Position, Position2D, Range};
334    use crate::bridge::translator::testing::*;
335    use crate::config::ServerId;
336
337    #[tokio::test]
338    async fn test_handle_call_hierarchy_prepare_invalid_position_zero() {
339        let translator = Translator::new();
340        let result = translator
341            .handle_call_hierarchy_prepare(
342                "/tmp/test.rs".to_string(),
343                Position {
344                    line: 0,
345                    character: 1,
346                },
347            )
348            .await;
349        assert!(matches!(result, Err(Error::InvalidToolParams(_))));
350
351        let result = translator
352            .handle_call_hierarchy_prepare(
353                "/tmp/test.rs".to_string(),
354                Position {
355                    line: 1,
356                    character: 0,
357                },
358            )
359            .await;
360        assert!(matches!(result, Err(Error::InvalidToolParams(_))));
361    }
362
363    #[tokio::test]
364    async fn test_handle_call_hierarchy_prepare_invalid_position_too_large() {
365        let translator = Translator::new();
366        let result = translator
367            .handle_call_hierarchy_prepare(
368                "/tmp/test.rs".to_string(),
369                Position {
370                    line: 1_000_001,
371                    character: 1,
372                },
373            )
374            .await;
375        assert!(matches!(result, Err(Error::InvalidToolParams(_))));
376
377        let result = translator
378            .handle_call_hierarchy_prepare(
379                "/tmp/test.rs".to_string(),
380                Position {
381                    line: 1,
382                    character: 1_000_001,
383                },
384            )
385            .await;
386        assert!(matches!(result, Err(Error::InvalidToolParams(_))));
387    }
388
389    #[tokio::test]
390    async fn test_handle_incoming_calls_invalid_json() {
391        let translator = Translator::new();
392        let invalid_item = serde_json::json!({"invalid": "structure"});
393        let result = translator.handle_incoming_calls(invalid_item).await;
394        assert!(matches!(result, Err(Error::InvalidToolParams(_))));
395    }
396
397    #[tokio::test]
398    async fn test_handle_outgoing_calls_invalid_json() {
399        let translator = Translator::new();
400        let invalid_item = serde_json::json!({"invalid": "structure"});
401        let result = translator.handle_outgoing_calls(invalid_item).await;
402        assert!(matches!(result, Err(Error::InvalidToolParams(_))));
403    }
404
405    /// Builds a `CallHierarchyItemResult` JSON value pointing at `path`, for
406    /// driving `handle_incoming_calls`/`handle_outgoing_calls` directly
407    /// without a preceding `prepare_call_hierarchy` round trip.
408    fn call_hierarchy_item_json(uri: &str) -> serde_json::Value {
409        serde_json::to_value(CallHierarchyItemResult {
410            name: "queried_fn".to_string(),
411            kind: 12,
412            detail: None,
413            uri: uri.to_string(),
414            range: Range {
415                start: Position2D {
416                    line: 1,
417                    character: 1,
418                },
419                end: Position2D {
420                    line: 1,
421                    character: 4,
422                },
423            },
424            selection_range: Range {
425                start: Position2D {
426                    line: 1,
427                    character: 1,
428                },
429                end: Position2D {
430                    line: 1,
431                    character: 4,
432                },
433            },
434            data: None,
435            out_of_workspace: false,
436        })
437        .unwrap()
438    }
439
440    /// #423 regression: `handle_incoming_calls` is a whole-workspace query of
441    /// the same class as `references` and must be gated on indexing
442    /// readiness the same way, instead of bypassing `prepare_gated_document`
443    /// entirely.
444    #[tokio::test(start_paused = true)]
445    async fn test_handle_incoming_calls_returns_workspace_indexing_error_when_loading() {
446        let dir = TempDir::new().unwrap();
447        let server_id = ServerId::from("rust");
448        let caps = lsp_types::ServerCapabilities {
449            call_hierarchy_provider: Some(lsp_types::CallHierarchyProvider::Bool(true)),
450            ..Default::default()
451        };
452        let (translator, _server) = translator_with_capabilities(&dir, &server_id, caps);
453
454        let cache = Arc::new(Mutex::new(NotificationCache::new()));
455        cache.lock().await.observe_indexing_signal(
456            &server_id,
457            "experimental/serverStatus",
458            Some(&serde_json::json!({"quiescent": false})),
459        );
460        let translator = translator.with_notification_cache(cache);
461
462        let path = dir.path().join("queried.rs");
463        fs::write(&path, "fn queried() {}").unwrap();
464        let uri = Url::from_file_path(&path).unwrap().to_string();
465
466        let err = translator
467            .handle_incoming_calls(call_hierarchy_item_json(&uri))
468            .await
469            .unwrap_err();
470
471        assert!(matches!(
472            err,
473            Error::WorkspaceIndexing { server_id: id, .. } if id == server_id
474        ));
475    }
476
477    /// #423 regression: companion for `handle_outgoing_calls` -- see
478    /// `test_handle_incoming_calls_returns_workspace_indexing_error_when_loading`.
479    #[tokio::test(start_paused = true)]
480    async fn test_handle_outgoing_calls_returns_workspace_indexing_error_when_loading() {
481        let dir = TempDir::new().unwrap();
482        let server_id = ServerId::from("rust");
483        let caps = lsp_types::ServerCapabilities {
484            call_hierarchy_provider: Some(lsp_types::CallHierarchyProvider::Bool(true)),
485            ..Default::default()
486        };
487        let (translator, _server) = translator_with_capabilities(&dir, &server_id, caps);
488
489        let cache = Arc::new(Mutex::new(NotificationCache::new()));
490        cache.lock().await.observe_indexing_signal(
491            &server_id,
492            "experimental/serverStatus",
493            Some(&serde_json::json!({"quiescent": false})),
494        );
495        let translator = translator.with_notification_cache(cache);
496
497        let path = dir.path().join("queried.rs");
498        fs::write(&path, "fn queried() {}").unwrap();
499        let uri = Url::from_file_path(&path).unwrap().to_string();
500
501        let err = translator
502            .handle_outgoing_calls(call_hierarchy_item_json(&uri))
503            .await
504            .unwrap_err();
505
506        assert!(matches!(
507            err,
508            Error::WorkspaceIndexing { server_id: id, .. } if id == server_id
509        ));
510    }
511
512    /// S4 lock-in for the documented `Uri`-validation-loss behavior change
513    /// (see the CHANGELOG entry for #297): `parse_mcp_call_hierarchy_item`
514    /// can no longer reject a malformed `uri` field at construction time
515    /// (`gen-lsp-types`'s `Uri` has no validating parse). This drives a
516    /// structurally-valid item whose `uri` field is `file://`-prefixed (so
517    /// `parse_file_uri`'s scheme check still passes, same as before the
518    /// migration) but points at a path that does not exist on disk, through
519    /// the real `handle_incoming_calls`/`handle_outgoing_calls` handlers, and
520    /// pins the actual resulting error: `Error::FileIo` from
521    /// `validate_path`'s `canonicalize()` call, not the old construction-time
522    /// `Error::InvalidToolParams`.
523    #[tokio::test]
524    async fn test_handle_incoming_calls_with_nonexistent_file_uri_returns_file_io_not_invalid_uri()
525    {
526        let mut translator = Translator::new();
527        // A workspace root is required so `validate_path` reaches
528        // `canonicalize()` instead of failing closed on `NoWorkspaceRoots`.
529        #[cfg(windows)]
530        translator.set_workspace_roots(vec![std::path::PathBuf::from(r"C:\")]);
531        #[cfg(not(windows))]
532        translator.set_workspace_roots(vec![std::path::PathBuf::from("/")]);
533        // `Url::to_file_path` on Windows requires a drive-letter first path
534        // segment; a Unix-style path with none fails to convert at all
535        // (`uri_to_path` returns `None`, i.e. `Error::InvalidToolParams`)
536        // before ever reaching `canonicalize()`, trivially failing this
537        // assertion for the wrong reason. Use a drive-letter path so the
538        // test exercises the intended `FileIo` path on every platform.
539        #[cfg(windows)]
540        let uri = "file:///C:/this/path/does/not/exist/anywhere.rs";
541        #[cfg(not(windows))]
542        let uri = "file:///this/path/does/not/exist/anywhere.rs";
543        let item = serde_json::json!({
544            "name": "foo",
545            "kind": 12,
546            "uri": uri,
547            "range": {
548                "start": {"line": 1, "character": 1},
549                "end": {"line": 1, "character": 1}
550            },
551            "selectionRange": {
552                "start": {"line": 1, "character": 1},
553                "end": {"line": 1, "character": 1}
554            }
555        });
556
557        let result = translator.handle_incoming_calls(item).await;
558
559        assert!(
560            matches!(result, Err(Error::FileIo { .. })),
561            "expected Error::FileIo from the canonicalize() failure now that Uri construction \
562             cannot itself reject a malformed uri, got {result:?}"
563        );
564    }
565
566    /// As above, through `handle_outgoing_calls` -- same
567    /// `parse_mcp_call_hierarchy_item` code path, different caller.
568    #[tokio::test]
569    async fn test_handle_outgoing_calls_with_nonexistent_file_uri_returns_file_io_not_invalid_uri()
570    {
571        let mut translator = Translator::new();
572        // A workspace root is required so `validate_path` reaches
573        // `canonicalize()` instead of failing closed on `NoWorkspaceRoots`.
574        #[cfg(windows)]
575        translator.set_workspace_roots(vec![std::path::PathBuf::from(r"C:\")]);
576        #[cfg(not(windows))]
577        translator.set_workspace_roots(vec![std::path::PathBuf::from("/")]);
578        #[cfg(windows)]
579        let uri = "file:///C:/this/path/does/not/exist/anywhere.rs";
580        #[cfg(not(windows))]
581        let uri = "file:///this/path/does/not/exist/anywhere.rs";
582        let item = serde_json::json!({
583            "name": "foo",
584            "kind": 12,
585            "uri": uri,
586            "range": {
587                "start": {"line": 1, "character": 1},
588                "end": {"line": 1, "character": 1}
589            },
590            "selectionRange": {
591                "start": {"line": 1, "character": 1},
592                "end": {"line": 1, "character": 1}
593            }
594        });
595
596        let result = translator.handle_outgoing_calls(item).await;
597
598        assert!(
599            matches!(result, Err(Error::FileIo { .. })),
600            "expected Error::FileIo from the canonicalize() failure now that Uri construction \
601             cannot itself reject a malformed uri, got {result:?}"
602        );
603    }
604
605    #[tokio::test]
606    async fn test_convert_call_hierarchy_item_kind_is_numeric() {
607        let item = lsp_types::CallHierarchyItem {
608            name: "my_fn".to_string(),
609            kind: lsp_types::SymbolKind::Function,
610            tags: None,
611            detail: None,
612            uri: lsp_types::Uri::from("file:///tmp/test.rs"),
613            range: lsp_types::Range {
614                start: lsp_types::Position {
615                    line: 0,
616                    character: 0,
617                },
618                end: lsp_types::Position {
619                    line: 0,
620                    character: 5,
621                },
622            },
623            selection_range: lsp_types::Range {
624                start: lsp_types::Position {
625                    line: 0,
626                    character: 0,
627                },
628                end: lsp_types::Position {
629                    line: 0,
630                    character: 5,
631                },
632            },
633            data: None,
634        };
635        let result = convert_call_hierarchy_item(item, &test_ctx()).await;
636        // SymbolKind::Function is LSP integer 12
637        assert_eq!(result.kind, 12u32);
638        assert_eq!(result.name, "my_fn");
639    }
640
641    /// #467 M2/tester gap 2: pins that `call_hierarchy_item_to_lsp`'s reverse
642    /// `u32 -> SymbolKind` conversion round-trips a non-`Function` kind
643    /// through to the outbound wire request unchanged -- not the
644    /// `unwrap_or(Function)` fallback it used to fabricate on a conversion
645    /// failure that could not actually occur.
646    #[tokio::test]
647    async fn test_handle_incoming_calls_round_trips_non_function_kind() {
648        let dir = TempDir::new().unwrap();
649        let server_id = ServerId::from("rust");
650        let caps = lsp_types::ServerCapabilities {
651            call_hierarchy_provider: Some(lsp_types::CallHierarchyProvider::Bool(true)),
652            ..Default::default()
653        };
654        let (translator, mut server) = translator_with_capabilities(&dir, &server_id, caps);
655
656        let queried_path = dir.path().join("queried.rs");
657        fs::write(&queried_path, "fn queried() {}").unwrap();
658        let queried_uri = Url::from_file_path(&queried_path).unwrap().to_string();
659
660        let item = CallHierarchyItemResult {
661            name: "queried_method".to_string(),
662            kind: 6, // SymbolKind::Method
663            detail: None,
664            uri: queried_uri,
665            range: Range {
666                start: Position2D {
667                    line: 1,
668                    character: 1,
669                },
670                end: Position2D {
671                    line: 1,
672                    character: 4,
673                },
674            },
675            selection_range: Range {
676                start: Position2D {
677                    line: 1,
678                    character: 1,
679                },
680                end: Position2D {
681                    line: 1,
682                    character: 4,
683                },
684            },
685            data: None,
686            out_of_workspace: false,
687        };
688
689        let translator = Arc::new(translator);
690        let handle = {
691            let translator = Arc::clone(&translator);
692            let item = serde_json::to_value(item).unwrap();
693            tokio::spawn(async move { translator.handle_incoming_calls(item).await })
694        };
695
696        let mut wire = BufReader::new(&mut server.write_stdout);
697        let opened = read_framed_message(&mut wire).await;
698        assert_eq!(opened["method"], "textDocument/didOpen");
699
700        let request = read_framed_message(&mut wire).await;
701        assert_eq!(request["method"], "callHierarchy/incomingCalls");
702        assert_eq!(
703            request["params"]["item"]["kind"], 6,
704            "the reverse u32 -> SymbolKind conversion must round-trip a non-Function kind \
705             exactly, not fall back to Function (12)"
706        );
707
708        write_response(
709            &mut server.read_half_stdin,
710            &request["id"],
711            serde_json::json!([]),
712        )
713        .await;
714
715        let result = timeout(Duration::from_secs(2), handle)
716            .await
717            .expect("handler call should not hang")
718            .unwrap()
719            .unwrap();
720        assert!(result.calls.is_empty());
721    }
722
723    /// Per the LSP spec, an incoming call's `fromRanges` are ranges within
724    /// the *caller's* document (`call.from.uri`), not the queried item's
725    /// document -- `handle_incoming_calls` must convert them against
726    /// `caller.rs`'s own content, not `queried.rs`'s. Uses a UTF-8-negotiated
727    /// server and two files with different multibyte content, so converting
728    /// against the wrong file's line text produces a different, wrong
729    /// answer: `"aöb"` (caller) puts LSP byte offset 3 at UTF-16 column 3
730    /// (`ö` is 2 UTF-8 bytes / 1 UTF-16 unit), while the ASCII `"abc"`
731    /// (queried item) would put the same byte offset at column 4.
732    #[tokio::test]
733    async fn test_handle_incoming_calls_from_ranges_convert_against_callers_own_uri() {
734        let dir = TempDir::new().unwrap();
735        let server_id = ServerId::from("rust");
736        let caps = lsp_types::ServerCapabilities {
737            call_hierarchy_provider: Some(lsp_types::CallHierarchyProvider::Bool(true)),
738            ..Default::default()
739        };
740        let (translator, mut server) = translator_with_capabilities_and_encoding(
741            &dir,
742            &server_id,
743            caps,
744            lsp_types::PositionEncodingKind::UTF8,
745        );
746
747        let queried_path = dir.path().join("queried.rs");
748        fs::write(&queried_path, "abc").unwrap();
749        let queried_uri = Url::from_file_path(&queried_path).unwrap().to_string();
750
751        let caller_path = dir.path().join("caller.rs");
752        fs::write(&caller_path, "aöb").unwrap();
753        let caller_uri = Url::from_file_path(&caller_path).unwrap().to_string();
754
755        let item = CallHierarchyItemResult {
756            name: "queried_fn".to_string(),
757            kind: 12,
758            detail: None,
759            uri: queried_uri,
760            range: Range {
761                start: Position2D {
762                    line: 1,
763                    character: 1,
764                },
765                end: Position2D {
766                    line: 1,
767                    character: 4,
768                },
769            },
770            selection_range: Range {
771                start: Position2D {
772                    line: 1,
773                    character: 1,
774                },
775                end: Position2D {
776                    line: 1,
777                    character: 4,
778                },
779            },
780            data: None,
781            out_of_workspace: false,
782        };
783
784        let translator = Arc::new(translator);
785        let handle = {
786            let translator = Arc::clone(&translator);
787            let item = serde_json::to_value(item).unwrap();
788            tokio::spawn(async move { translator.handle_incoming_calls(item).await })
789        };
790
791        let mut wire = BufReader::new(&mut server.write_stdout);
792        let opened = read_framed_message(&mut wire).await;
793        assert_eq!(opened["method"], "textDocument/didOpen");
794
795        let request = read_framed_message(&mut wire).await;
796        assert_eq!(request["method"], "callHierarchy/incomingCalls");
797
798        write_response(
799            &mut server.read_half_stdin,
800            &request["id"],
801            serde_json::json!([{
802                "from": {
803                    "name": "caller_fn",
804                    "kind": 12,
805                    "uri": caller_uri,
806                    "range": {
807                        "start": {"line": 0, "character": 0},
808                        "end": {"line": 0, "character": 1}
809                    },
810                    "selectionRange": {
811                        "start": {"line": 0, "character": 0},
812                        "end": {"line": 0, "character": 1}
813                    }
814                },
815                "fromRanges": [{
816                    "start": {"line": 0, "character": 0},
817                    "end": {"line": 0, "character": 3}
818                }]
819            }]),
820        )
821        .await;
822
823        let result = timeout(Duration::from_secs(2), handle)
824            .await
825            .expect("handler call should not hang")
826            .unwrap()
827            .unwrap();
828
829        assert_eq!(result.calls.len(), 1);
830        let from_range = &result.calls[0].from_ranges[0];
831        assert_eq!(
832            from_range.end.character, 3,
833            "fromRanges must convert against the caller's own file (\"aöb\"), not the queried \
834             item's (\"abc\") -- a byte offset of 3 is UTF-16 column 3 in the former, 4 in the \
835             latter"
836        );
837        assert!(
838            !result.calls[0].from.out_of_workspace,
839            "an in-workspace caller must not be marked out_of_workspace"
840        );
841    }
842
843    /// Per the LSP spec, an outgoing call's `fromRanges` are ranges within
844    /// the *queried* item's own document, not the callee's (`call.to.uri`) --
845    /// the inverse directional convention from incoming calls, tested above.
846    #[tokio::test]
847    async fn test_handle_outgoing_calls_from_ranges_convert_against_queried_uri() {
848        let dir = TempDir::new().unwrap();
849        let server_id = ServerId::from("rust");
850        let caps = lsp_types::ServerCapabilities {
851            call_hierarchy_provider: Some(lsp_types::CallHierarchyProvider::Bool(true)),
852            ..Default::default()
853        };
854        let (translator, mut server) = translator_with_capabilities_and_encoding(
855            &dir,
856            &server_id,
857            caps,
858            lsp_types::PositionEncodingKind::UTF8,
859        );
860
861        let queried_path = dir.path().join("queried.rs");
862        fs::write(&queried_path, "aöb").unwrap();
863        let queried_uri = Url::from_file_path(&queried_path).unwrap().to_string();
864
865        let callee_path = dir.path().join("callee.rs");
866        fs::write(&callee_path, "abc").unwrap();
867        let callee_uri = Url::from_file_path(&callee_path).unwrap().to_string();
868
869        let item = CallHierarchyItemResult {
870            name: "queried_fn".to_string(),
871            kind: 12,
872            detail: None,
873            uri: queried_uri,
874            range: Range {
875                start: Position2D {
876                    line: 1,
877                    character: 1,
878                },
879                end: Position2D {
880                    line: 1,
881                    character: 4,
882                },
883            },
884            selection_range: Range {
885                start: Position2D {
886                    line: 1,
887                    character: 1,
888                },
889                end: Position2D {
890                    line: 1,
891                    character: 4,
892                },
893            },
894            data: None,
895            out_of_workspace: false,
896        };
897
898        let translator = Arc::new(translator);
899        let handle = {
900            let translator = Arc::clone(&translator);
901            let item = serde_json::to_value(item).unwrap();
902            tokio::spawn(async move { translator.handle_outgoing_calls(item).await })
903        };
904
905        let mut wire = BufReader::new(&mut server.write_stdout);
906        let opened = read_framed_message(&mut wire).await;
907        assert_eq!(opened["method"], "textDocument/didOpen");
908
909        let request = read_framed_message(&mut wire).await;
910        assert_eq!(request["method"], "callHierarchy/outgoingCalls");
911
912        write_response(
913            &mut server.read_half_stdin,
914            &request["id"],
915            serde_json::json!([{
916                "to": {
917                    "name": "callee_fn",
918                    "kind": 12,
919                    "uri": callee_uri,
920                    "range": {
921                        "start": {"line": 0, "character": 0},
922                        "end": {"line": 0, "character": 1}
923                    },
924                    "selectionRange": {
925                        "start": {"line": 0, "character": 0},
926                        "end": {"line": 0, "character": 1}
927                    }
928                },
929                "fromRanges": [{
930                    "start": {"line": 0, "character": 0},
931                    "end": {"line": 0, "character": 3}
932                }]
933            }]),
934        )
935        .await;
936
937        let result = timeout(Duration::from_secs(2), handle)
938            .await
939            .expect("handler call should not hang")
940            .unwrap()
941            .unwrap();
942
943        assert_eq!(result.calls.len(), 1);
944        let from_range = &result.calls[0].from_ranges[0];
945        assert_eq!(
946            from_range.end.character, 3,
947            "fromRanges must convert against the queried item's own file (\"aöb\"), not the \
948             callee's (\"abc\") -- a byte offset of 3 is UTF-16 column 3 in the former, 4 in \
949             the latter"
950        );
951    }
952
953    /// #415 (revised per critic C1): an incoming call whose caller
954    /// (`call.from.uri`) lies outside every configured workspace root must
955    /// still be returned -- a caller in the standard library or a
956    /// crates.io dependency is normal, expected call-hierarchy navigation,
957    /// not an attack. See `navigation.rs`'s
958    /// `test_handle_definition_does_not_filter_out_of_workspace_location`
959    /// for the same policy on goto-X locations.
960    #[tokio::test]
961    async fn test_handle_incoming_calls_does_not_filter_out_of_workspace_caller() {
962        let dir = TempDir::new().unwrap();
963        let server_id = ServerId::from("rust");
964        let caps = lsp_types::ServerCapabilities {
965            call_hierarchy_provider: Some(lsp_types::CallHierarchyProvider::Bool(true)),
966            ..Default::default()
967        };
968        let (translator, mut server) = translator_with_capabilities(&dir, &server_id, caps);
969
970        let queried_path = dir.path().join("queried.rs");
971        fs::write(&queried_path, "fn queried() {}").unwrap();
972        let queried_uri = Url::from_file_path(&queried_path).unwrap().to_string();
973        let outside_uri = "file:///outside/workspace/stdlib.rs";
974
975        let item = CallHierarchyItemResult {
976            name: "queried_fn".to_string(),
977            kind: 12,
978            detail: None,
979            uri: queried_uri,
980            range: Range {
981                start: Position2D {
982                    line: 1,
983                    character: 1,
984                },
985                end: Position2D {
986                    line: 1,
987                    character: 4,
988                },
989            },
990            selection_range: Range {
991                start: Position2D {
992                    line: 1,
993                    character: 1,
994                },
995                end: Position2D {
996                    line: 1,
997                    character: 4,
998                },
999            },
1000            data: None,
1001            out_of_workspace: false,
1002        };
1003
1004        let translator = Arc::new(translator);
1005        let handle = {
1006            let translator = Arc::clone(&translator);
1007            let item = serde_json::to_value(item).unwrap();
1008            tokio::spawn(async move { translator.handle_incoming_calls(item).await })
1009        };
1010
1011        let mut wire = BufReader::new(&mut server.write_stdout);
1012        let opened = read_framed_message(&mut wire).await;
1013        assert_eq!(opened["method"], "textDocument/didOpen");
1014
1015        let request = read_framed_message(&mut wire).await;
1016        assert_eq!(request["method"], "callHierarchy/incomingCalls");
1017
1018        write_response(
1019            &mut server.read_half_stdin,
1020            &request["id"],
1021            serde_json::json!([{
1022                "from": {
1023                    "name": "caller_fn",
1024                    "kind": 12,
1025                    "uri": outside_uri,
1026                    "range": {
1027                        "start": {"line": 0, "character": 0},
1028                        "end": {"line": 0, "character": 1}
1029                    },
1030                    "selectionRange": {
1031                        "start": {"line": 0, "character": 0},
1032                        "end": {"line": 0, "character": 1}
1033                    }
1034                },
1035                "fromRanges": [{
1036                    "start": {"line": 0, "character": 0},
1037                    "end": {"line": 0, "character": 1}
1038                }]
1039            }]),
1040        )
1041        .await;
1042
1043        let result = timeout(Duration::from_secs(2), handle)
1044            .await
1045            .expect("handler call should not hang")
1046            .unwrap()
1047            .unwrap();
1048
1049        assert_eq!(
1050            result.calls.len(),
1051            1,
1052            "an out-of-workspace caller must be returned, not dropped"
1053        );
1054        assert_eq!(result.calls[0].from.uri, outside_uri);
1055        assert!(
1056            result.calls[0].from.out_of_workspace,
1057            "an out-of-workspace caller must be marked out_of_workspace"
1058        );
1059    }
1060
1061    /// #415 (revised per critic C1) companion for outgoing calls: a callee
1062    /// (`call.to.uri`) outside every configured workspace root must still be
1063    /// returned -- see the incoming-calls test above for the rationale.
1064    #[tokio::test]
1065    async fn test_handle_outgoing_calls_does_not_filter_out_of_workspace_callee() {
1066        let dir = TempDir::new().unwrap();
1067        let server_id = ServerId::from("rust");
1068        let caps = lsp_types::ServerCapabilities {
1069            call_hierarchy_provider: Some(lsp_types::CallHierarchyProvider::Bool(true)),
1070            ..Default::default()
1071        };
1072        let (translator, mut server) = translator_with_capabilities(&dir, &server_id, caps);
1073
1074        let queried_path = dir.path().join("queried.rs");
1075        fs::write(&queried_path, "fn queried() {}").unwrap();
1076        let queried_uri = Url::from_file_path(&queried_path).unwrap().to_string();
1077        let outside_uri = "file:///outside/workspace/stdlib.rs";
1078
1079        let item = CallHierarchyItemResult {
1080            name: "queried_fn".to_string(),
1081            kind: 12,
1082            detail: None,
1083            uri: queried_uri,
1084            range: Range {
1085                start: Position2D {
1086                    line: 1,
1087                    character: 1,
1088                },
1089                end: Position2D {
1090                    line: 1,
1091                    character: 4,
1092                },
1093            },
1094            selection_range: Range {
1095                start: Position2D {
1096                    line: 1,
1097                    character: 1,
1098                },
1099                end: Position2D {
1100                    line: 1,
1101                    character: 4,
1102                },
1103            },
1104            data: None,
1105            out_of_workspace: false,
1106        };
1107
1108        let translator = Arc::new(translator);
1109        let handle = {
1110            let translator = Arc::clone(&translator);
1111            let item = serde_json::to_value(item).unwrap();
1112            tokio::spawn(async move { translator.handle_outgoing_calls(item).await })
1113        };
1114
1115        let mut wire = BufReader::new(&mut server.write_stdout);
1116        let opened = read_framed_message(&mut wire).await;
1117        assert_eq!(opened["method"], "textDocument/didOpen");
1118
1119        let request = read_framed_message(&mut wire).await;
1120        assert_eq!(request["method"], "callHierarchy/outgoingCalls");
1121
1122        write_response(
1123            &mut server.read_half_stdin,
1124            &request["id"],
1125            serde_json::json!([{
1126                "to": {
1127                    "name": "callee_fn",
1128                    "kind": 12,
1129                    "uri": outside_uri,
1130                    "range": {
1131                        "start": {"line": 0, "character": 0},
1132                        "end": {"line": 0, "character": 1}
1133                    },
1134                    "selectionRange": {
1135                        "start": {"line": 0, "character": 0},
1136                        "end": {"line": 0, "character": 1}
1137                    }
1138                },
1139                "fromRanges": [{
1140                    "start": {"line": 0, "character": 0},
1141                    "end": {"line": 0, "character": 1}
1142                }]
1143            }]),
1144        )
1145        .await;
1146
1147        let result = timeout(Duration::from_secs(2), handle)
1148            .await
1149            .expect("handler call should not hang")
1150            .unwrap()
1151            .unwrap();
1152
1153        assert_eq!(
1154            result.calls.len(),
1155            1,
1156            "an out-of-workspace callee must be returned, not dropped"
1157        );
1158        assert_eq!(result.calls[0].to.uri, outside_uri);
1159        assert!(
1160            result.calls[0].to.out_of_workspace,
1161            "an out-of-workspace callee must be marked out_of_workspace"
1162        );
1163    }
1164
1165    /// #411 regression: `prepare_call_hierarchy` -> `get_incoming_calls`
1166    /// round trip must resolve a percent-encoded URI (space, non-ASCII)
1167    /// back to the file it names. Before the fix, `parse_file_uri`
1168    /// raw-sliced the URI instead of decoding it, so `canonicalize()`
1169    /// failed with `ENOENT` even though the file exists.
1170    #[tokio::test]
1171    async fn test_prepare_then_incoming_calls_round_trip_percent_encoded_path() {
1172        let dir = TempDir::new().unwrap();
1173        let server_id = ServerId::from("rust");
1174        let caps = lsp_types::ServerCapabilities {
1175            call_hierarchy_provider: Some(lsp_types::CallHierarchyProvider::Bool(true)),
1176            ..Default::default()
1177        };
1178        let (translator, mut server) = translator_with_capabilities_and_encoding(
1179            &dir,
1180            &server_id,
1181            caps,
1182            lsp_types::PositionEncodingKind::UTF8,
1183        );
1184
1185        let file_path = dir.path().join("my file café.rs");
1186        fs::write(&file_path, "fn foo() {}").unwrap();
1187        let file_uri = Url::from_file_path(&file_path).unwrap().to_string();
1188        assert!(
1189            file_uri.contains("%20"),
1190            "test fixture must exercise percent-encoding"
1191        );
1192
1193        let translator = Arc::new(translator);
1194        let mut wire = BufReader::new(&mut server.write_stdout);
1195
1196        let prepare_handle = {
1197            let translator = Arc::clone(&translator);
1198            let path = file_path.to_string_lossy().into_owned();
1199            tokio::spawn(async move {
1200                translator
1201                    .handle_call_hierarchy_prepare(path, pos(1, 1))
1202                    .await
1203            })
1204        };
1205
1206        let opened = read_framed_message(&mut wire).await;
1207        assert_eq!(opened["method"], "textDocument/didOpen");
1208        let request = read_framed_message(&mut wire).await;
1209        assert_eq!(request["method"], "textDocument/prepareCallHierarchy");
1210
1211        write_response(
1212            &mut server.read_half_stdin,
1213            &request["id"],
1214            serde_json::json!([{
1215                "name": "foo",
1216                "kind": 12,
1217                "uri": file_uri,
1218                "range": {
1219                    "start": {"line": 0, "character": 0},
1220                    "end": {"line": 0, "character": 11}
1221                },
1222                "selectionRange": {
1223                    "start": {"line": 0, "character": 3},
1224                    "end": {"line": 0, "character": 6}
1225                }
1226            }]),
1227        )
1228        .await;
1229
1230        let prepare_result = timeout(Duration::from_secs(2), prepare_handle)
1231            .await
1232            .expect("prepare should not hang")
1233            .unwrap()
1234            .unwrap();
1235        assert_eq!(prepare_result.items.len(), 1);
1236        let item = prepare_result.items[0].clone();
1237        assert!(item.uri.contains("%20"));
1238
1239        let incoming_handle = {
1240            let translator = Arc::clone(&translator);
1241            let item = serde_json::to_value(item).unwrap();
1242            tokio::spawn(async move { translator.handle_incoming_calls(item).await })
1243        };
1244
1245        let request = read_framed_message(&mut wire).await;
1246        assert_eq!(request["method"], "callHierarchy/incomingCalls");
1247
1248        write_response(
1249            &mut server.read_half_stdin,
1250            &request["id"],
1251            serde_json::json!([]),
1252        )
1253        .await;
1254
1255        let incoming_result = timeout(Duration::from_secs(2), incoming_handle)
1256            .await
1257            .expect("incoming calls should not hang")
1258            .unwrap();
1259
1260        assert!(
1261            incoming_result.is_ok(),
1262            "expected success resolving the percent-encoded path, got {incoming_result:?}"
1263        );
1264    }
1265}