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,
13};
14use super::encoding_ctx::EncodingCtx;
15use super::routing::MAX_POSITION_VALUE;
16use crate::config::ToolKind;
17use crate::error::{Error, Result};
18
19/// Whether a server's capabilities advertise `callHierarchyProvider` support.
20///
21/// Shared by `handle_call_hierarchy_prepare`, `handle_incoming_calls`, and
22/// `handle_outgoing_calls`, which all gate on the same capability field.
23const fn call_hierarchy_provider_supported(caps: &lsp_types::ServerCapabilities) -> bool {
24    matches!(
25        caps.call_hierarchy_provider,
26        Some(
27            lsp_types::CallHierarchyProvider::Bool(true)
28                | lsp_types::CallHierarchyProvider::CallHierarchyOptions(_)
29                | lsp_types::CallHierarchyProvider::CallHierarchyRegistrationOptions(_)
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    // `gen-lsp-types`'s `Uri` is an opaque string wrapper with no validating
54    // parse, so constructing it is infallible -- the malformed-URI rejection
55    // this call used to provide is gone. Downstream consumers (e.g.
56    // `parse_file_uri`) still validate the `file://` scheme and reject what
57    // they can't use.
58    let uri = lsp_types::Uri::from(mcp.uri.as_str());
59
60    Ok(ParsedCallHierarchyItem { uri, mcp })
61}
62
63/// Convert a parsed MCP call hierarchy item (1-based coordinates) into a
64/// `lsp_types::CallHierarchyItem` (0-based, in `ctx`'s negotiated encoding).
65async fn call_hierarchy_item_to_lsp(
66    parsed: ParsedCallHierarchyItem,
67    ctx: &EncodingCtx,
68) -> CallHierarchyItem {
69    let ParsedCallHierarchyItem { uri, mcp } = parsed;
70
71    // Round-trip via serde: `convert_call_hierarchy_item` stored the kind as a u32
72    // by serialising `SymbolKind`; we reverse this to reconstruct the same value.
73    let kind: lsp_types::SymbolKind = serde_json::from_value(serde_json::json!(mcp.kind))
74        .unwrap_or(lsp_types::SymbolKind::Function);
75    let range = ctx.denormalize_range(&uri, &mcp.range).await;
76    let selection_range = ctx.denormalize_range(&uri, &mcp.selection_range).await;
77
78    CallHierarchyItem {
79        name: mcp.name,
80        kind,
81        tags: None,
82        detail: mcp.detail,
83        uri,
84        range,
85        selection_range,
86        data: mcp.data,
87    }
88}
89
90/// Convert LSP call hierarchy item to MCP call hierarchy item.
91async fn convert_call_hierarchy_item(
92    item: CallHierarchyItem,
93    ctx: &EncodingCtx,
94) -> CallHierarchyItemResult {
95    let range = ctx.normalize_range(&item.uri, item.range).await;
96    let selection_range = ctx.normalize_range(&item.uri, item.selection_range).await;
97
98    CallHierarchyItemResult {
99        name: item.name,
100        kind: serde_json::to_value(item.kind)
101            .ok()
102            .and_then(|v| v.as_u64())
103            .and_then(|n| u32::try_from(n).ok())
104            .unwrap_or(0),
105        detail: item.detail,
106        uri: item.uri.to_string(),
107        range,
108        selection_range,
109        data: item.data,
110    }
111}
112
113impl Translator {
114    /// Handle call hierarchy prepare request.
115    ///
116    /// # Errors
117    ///
118    /// Returns an error if the LSP request fails, the file cannot be opened,
119    /// or the routed server does not advertise `callHierarchyProvider` support.
120    pub async fn handle_call_hierarchy_prepare(
121        &self,
122        file_path: String,
123        position: Position,
124    ) -> Result<CallHierarchyPrepareResult> {
125        let Position { line, character } = position;
126        // Validate position bounds
127        if line < 1 || character < 1 {
128            return Err(Error::InvalidToolParams(
129                "Line and character positions must be >= 1".to_string(),
130            ));
131        }
132
133        if line > MAX_POSITION_VALUE || character > MAX_POSITION_VALUE {
134            return Err(Error::InvalidToolParams(format!(
135                "Position values must be <= {MAX_POSITION_VALUE}"
136            )));
137        }
138
139        let (server_id, client, uri) = self
140            .prepare_gated_document(
141                &file_path,
142                ToolKind::CallHierarchy,
143                "callHierarchyProvider",
144                call_hierarchy_provider_supported,
145            )
146            .await?;
147        let ctx = self.encoding_ctx(&server_id);
148        let lsp_position = ctx.to_lsp(&uri, line, character).await;
149
150        let params = LspCallHierarchyPrepareParams {
151            text_document_position_params: TextDocumentPositionParams {
152                text_document: TextDocumentIdentifier { uri },
153                position: lsp_position,
154            },
155            work_done_progress_params: WorkDoneProgressParams::default(),
156        };
157
158        let response = client
159            .request_typed::<lsp_types::CallHierarchyPrepareRequest>(
160                params,
161                client.request_timeout(),
162            )
163            .await?;
164
165        // Pre-allocate and build result
166        let lsp_items = response.unwrap_or_default();
167        let mut items = Vec::with_capacity(lsp_items.len());
168        for item in lsp_items {
169            items.push(convert_call_hierarchy_item(item, &ctx).await);
170        }
171
172        Ok(CallHierarchyPrepareResult { items })
173    }
174
175    /// Handle incoming calls request.
176    ///
177    /// # Errors
178    ///
179    /// Returns an error if the LSP request fails, the item is invalid, or the
180    /// routed server does not advertise `callHierarchyProvider` support.
181    pub async fn handle_incoming_calls(
182        &self,
183        item: serde_json::Value,
184    ) -> Result<IncomingCallsResult> {
185        // Deserialize as our own type (1-based coords).
186        let parsed = parse_mcp_call_hierarchy_item(item)?;
187
188        // Parse and validate the URI. Resolved with the same ToolKind as
189        // `handle_call_hierarchy_prepare` -- the opaque item this call
190        // receives is only meaningful to the server that produced it, and
191        // that server is guaranteed to be the same one `prepare` synced the
192        // document to since both resolve via the same (language, tool) route.
193        let path = self.parse_file_uri(&parsed.uri)?;
194        let (server_id, client) = self
195            .resolve_client_for_file(&path, ToolKind::CallHierarchy)
196            .await?;
197        self.require_capability(
198            &server_id,
199            "callHierarchyProvider",
200            call_hierarchy_provider_supported,
201        )?;
202        let ctx = self.encoding_ctx(&server_id);
203        let lsp_item = call_hierarchy_item_to_lsp(parsed, &ctx).await;
204
205        let params = CallHierarchyIncomingCallsParams {
206            item: lsp_item,
207            work_done_progress_params: WorkDoneProgressParams::default(),
208            partial_result_params: PartialResultParams::default(),
209        };
210
211        let response = client
212            .request_typed::<lsp_types::CallHierarchyIncomingCallsRequest>(
213                params,
214                client.request_timeout(),
215            )
216            .await?;
217
218        // Pre-allocate and build result
219        let lsp_calls = response.unwrap_or_default();
220        let mut calls = Vec::with_capacity(lsp_calls.len());
221
222        for call in lsp_calls {
223            // Per the LSP spec, `fromRanges` are ranges within the *caller's*
224            // document (`call.from.uri`), not the queried item's document.
225            let from_uri = call.from.uri.clone();
226            let from_ranges = {
227                let mut ranges = Vec::with_capacity(call.from_ranges.len());
228                for range in call.from_ranges {
229                    ranges.push(ctx.normalize_range(&from_uri, range).await);
230                }
231                ranges
232            };
233
234            calls.push(IncomingCall {
235                from: convert_call_hierarchy_item(call.from, &ctx).await,
236                from_ranges,
237            });
238        }
239
240        Ok(IncomingCallsResult { calls })
241    }
242
243    /// Handle outgoing calls request.
244    ///
245    /// # Errors
246    ///
247    /// Returns an error if the LSP request fails, the item is invalid, or the
248    /// routed server does not advertise `callHierarchyProvider` support.
249    pub async fn handle_outgoing_calls(
250        &self,
251        item: serde_json::Value,
252    ) -> Result<OutgoingCallsResult> {
253        // Deserialize as our own type (1-based coords).
254        let parsed = parse_mcp_call_hierarchy_item(item)?;
255
256        // Parse and validate the URI. Same ToolKind/route as `prepare` and
257        // `handle_incoming_calls` -- see that function's comment.
258        let path = self.parse_file_uri(&parsed.uri)?;
259        let (server_id, client) = self
260            .resolve_client_for_file(&path, ToolKind::CallHierarchy)
261            .await?;
262        self.require_capability(
263            &server_id,
264            "callHierarchyProvider",
265            call_hierarchy_provider_supported,
266        )?;
267        let ctx = self.encoding_ctx(&server_id);
268        // Per the LSP spec, an outgoing call's `fromRanges` are ranges within
269        // the *queried* item's own document, not the callee's (`call.to.uri`).
270        let source_uri = parsed.uri.clone();
271        let lsp_item = call_hierarchy_item_to_lsp(parsed, &ctx).await;
272
273        let params = CallHierarchyOutgoingCallsParams {
274            item: lsp_item,
275            work_done_progress_params: WorkDoneProgressParams::default(),
276            partial_result_params: PartialResultParams::default(),
277        };
278
279        let response = client
280            .request_typed::<lsp_types::CallHierarchyOutgoingCallsRequest>(
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::{Position, 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(
331                "/tmp/test.rs".to_string(),
332                Position {
333                    line: 0,
334                    character: 1,
335                },
336            )
337            .await;
338        assert!(matches!(result, Err(Error::InvalidToolParams(_))));
339
340        let result = translator
341            .handle_call_hierarchy_prepare(
342                "/tmp/test.rs".to_string(),
343                Position {
344                    line: 1,
345                    character: 0,
346                },
347            )
348            .await;
349        assert!(matches!(result, Err(Error::InvalidToolParams(_))));
350    }
351
352    #[tokio::test]
353    async fn test_handle_call_hierarchy_prepare_invalid_position_too_large() {
354        let translator = Translator::new();
355        let result = translator
356            .handle_call_hierarchy_prepare(
357                "/tmp/test.rs".to_string(),
358                Position {
359                    line: 1_000_001,
360                    character: 1,
361                },
362            )
363            .await;
364        assert!(matches!(result, Err(Error::InvalidToolParams(_))));
365
366        let result = translator
367            .handle_call_hierarchy_prepare(
368                "/tmp/test.rs".to_string(),
369                Position {
370                    line: 1,
371                    character: 1_000_001,
372                },
373            )
374            .await;
375        assert!(matches!(result, Err(Error::InvalidToolParams(_))));
376    }
377
378    #[tokio::test]
379    async fn test_handle_incoming_calls_invalid_json() {
380        let translator = Translator::new();
381        let invalid_item = serde_json::json!({"invalid": "structure"});
382        let result = translator.handle_incoming_calls(invalid_item).await;
383        assert!(matches!(result, Err(Error::InvalidToolParams(_))));
384    }
385
386    #[tokio::test]
387    async fn test_handle_outgoing_calls_invalid_json() {
388        let translator = Translator::new();
389        let invalid_item = serde_json::json!({"invalid": "structure"});
390        let result = translator.handle_outgoing_calls(invalid_item).await;
391        assert!(matches!(result, Err(Error::InvalidToolParams(_))));
392    }
393
394    /// S4 lock-in for the documented `Uri`-validation-loss behavior change
395    /// (see the CHANGELOG entry for #297): `parse_mcp_call_hierarchy_item`
396    /// can no longer reject a malformed `uri` field at construction time
397    /// (`gen-lsp-types`'s `Uri` has no validating parse). This drives a
398    /// structurally-valid item whose `uri` field is `file://`-prefixed (so
399    /// `parse_file_uri`'s scheme check still passes, same as before the
400    /// migration) but points at a path that does not exist on disk, through
401    /// the real `handle_incoming_calls`/`handle_outgoing_calls` handlers, and
402    /// pins the actual resulting error: `Error::FileIo` from
403    /// `validate_path`'s `canonicalize()` call, not the old construction-time
404    /// `Error::InvalidToolParams`.
405    #[tokio::test]
406    async fn test_handle_incoming_calls_with_nonexistent_file_uri_returns_file_io_not_invalid_uri()
407    {
408        let translator = Translator::new();
409        let item = serde_json::json!({
410            "name": "foo",
411            "kind": 12,
412            "uri": "file:///this/path/does/not/exist/anywhere.rs",
413            "range": {
414                "start": {"line": 1, "character": 1},
415                "end": {"line": 1, "character": 1}
416            },
417            "selectionRange": {
418                "start": {"line": 1, "character": 1},
419                "end": {"line": 1, "character": 1}
420            }
421        });
422
423        let result = translator.handle_incoming_calls(item).await;
424
425        assert!(
426            matches!(result, Err(Error::FileIo { .. })),
427            "expected Error::FileIo from the canonicalize() failure now that Uri construction \
428             cannot itself reject a malformed uri, got {result:?}"
429        );
430    }
431
432    /// As above, through `handle_outgoing_calls` -- same
433    /// `parse_mcp_call_hierarchy_item` code path, different caller.
434    #[tokio::test]
435    async fn test_handle_outgoing_calls_with_nonexistent_file_uri_returns_file_io_not_invalid_uri()
436    {
437        let translator = Translator::new();
438        let item = serde_json::json!({
439            "name": "foo",
440            "kind": 12,
441            "uri": "file:///this/path/does/not/exist/anywhere.rs",
442            "range": {
443                "start": {"line": 1, "character": 1},
444                "end": {"line": 1, "character": 1}
445            },
446            "selectionRange": {
447                "start": {"line": 1, "character": 1},
448                "end": {"line": 1, "character": 1}
449            }
450        });
451
452        let result = translator.handle_outgoing_calls(item).await;
453
454        assert!(
455            matches!(result, Err(Error::FileIo { .. })),
456            "expected Error::FileIo from the canonicalize() failure now that Uri construction \
457             cannot itself reject a malformed uri, got {result:?}"
458        );
459    }
460
461    #[tokio::test]
462    async fn test_convert_call_hierarchy_item_kind_is_numeric() {
463        let item = lsp_types::CallHierarchyItem {
464            name: "my_fn".to_string(),
465            kind: lsp_types::SymbolKind::Function,
466            tags: None,
467            detail: None,
468            uri: lsp_types::Uri::from("file:///tmp/test.rs"),
469            range: lsp_types::Range {
470                start: lsp_types::Position {
471                    line: 0,
472                    character: 0,
473                },
474                end: lsp_types::Position {
475                    line: 0,
476                    character: 5,
477                },
478            },
479            selection_range: lsp_types::Range {
480                start: lsp_types::Position {
481                    line: 0,
482                    character: 0,
483                },
484                end: lsp_types::Position {
485                    line: 0,
486                    character: 5,
487                },
488            },
489            data: None,
490        };
491        let result = convert_call_hierarchy_item(item, &test_ctx()).await;
492        // SymbolKind::Function is LSP integer 12
493        assert_eq!(result.kind, 12u32);
494        assert_eq!(result.name, "my_fn");
495    }
496
497    /// Per the LSP spec, an incoming call's `fromRanges` are ranges within
498    /// the *caller's* document (`call.from.uri`), not the queried item's
499    /// document -- `handle_incoming_calls` must convert them against
500    /// `caller.rs`'s own content, not `queried.rs`'s. Uses a UTF-8-negotiated
501    /// server and two files with different multibyte content, so converting
502    /// against the wrong file's line text produces a different, wrong
503    /// answer: `"aöb"` (caller) puts LSP byte offset 3 at UTF-16 column 3
504    /// (`ö` is 2 UTF-8 bytes / 1 UTF-16 unit), while the ASCII `"abc"`
505    /// (queried item) would put the same byte offset at column 4.
506    #[tokio::test]
507    async fn test_handle_incoming_calls_from_ranges_convert_against_callers_own_uri() {
508        let dir = TempDir::new().unwrap();
509        let server_id = ServerId::from("rust");
510        let caps = lsp_types::ServerCapabilities {
511            call_hierarchy_provider: Some(lsp_types::CallHierarchyProvider::Bool(true)),
512            ..Default::default()
513        };
514        let (translator, mut server) = translator_with_capabilities_and_encoding(
515            &dir,
516            &server_id,
517            caps,
518            lsp_types::PositionEncodingKind::UTF8,
519        );
520
521        let queried_path = dir.path().join("queried.rs");
522        fs::write(&queried_path, "abc").unwrap();
523        let queried_uri = Url::from_file_path(&queried_path).unwrap().to_string();
524
525        let caller_path = dir.path().join("caller.rs");
526        fs::write(&caller_path, "aöb").unwrap();
527        let caller_uri = Url::from_file_path(&caller_path).unwrap().to_string();
528
529        let item = CallHierarchyItemResult {
530            name: "queried_fn".to_string(),
531            kind: 12,
532            detail: None,
533            uri: queried_uri,
534            range: Range {
535                start: Position2D {
536                    line: 1,
537                    character: 1,
538                },
539                end: Position2D {
540                    line: 1,
541                    character: 4,
542                },
543            },
544            selection_range: Range {
545                start: Position2D {
546                    line: 1,
547                    character: 1,
548                },
549                end: Position2D {
550                    line: 1,
551                    character: 4,
552                },
553            },
554            data: None,
555        };
556
557        let translator = Arc::new(translator);
558        let handle = {
559            let translator = Arc::clone(&translator);
560            let item = serde_json::to_value(item).unwrap();
561            tokio::spawn(async move { translator.handle_incoming_calls(item).await })
562        };
563
564        let mut wire = BufReader::new(&mut server.write_stdout);
565        let request = read_framed_message(&mut wire).await;
566        assert_eq!(request["method"], "callHierarchy/incomingCalls");
567
568        write_response(
569            &mut server.read_half_stdin,
570            &request["id"],
571            serde_json::json!([{
572                "from": {
573                    "name": "caller_fn",
574                    "kind": 12,
575                    "uri": caller_uri,
576                    "range": {
577                        "start": {"line": 0, "character": 0},
578                        "end": {"line": 0, "character": 1}
579                    },
580                    "selectionRange": {
581                        "start": {"line": 0, "character": 0},
582                        "end": {"line": 0, "character": 1}
583                    }
584                },
585                "fromRanges": [{
586                    "start": {"line": 0, "character": 0},
587                    "end": {"line": 0, "character": 3}
588                }]
589            }]),
590        )
591        .await;
592
593        let result = timeout(Duration::from_secs(2), handle)
594            .await
595            .expect("handler call should not hang")
596            .unwrap()
597            .unwrap();
598
599        assert_eq!(result.calls.len(), 1);
600        let from_range = &result.calls[0].from_ranges[0];
601        assert_eq!(
602            from_range.end.character, 3,
603            "fromRanges must convert against the caller's own file (\"aöb\"), not the queried \
604             item's (\"abc\") -- a byte offset of 3 is UTF-16 column 3 in the former, 4 in the \
605             latter"
606        );
607    }
608
609    /// Per the LSP spec, an outgoing call's `fromRanges` are ranges within
610    /// the *queried* item's own document, not the callee's (`call.to.uri`) --
611    /// the inverse directional convention from incoming calls, tested above.
612    #[tokio::test]
613    async fn test_handle_outgoing_calls_from_ranges_convert_against_queried_uri() {
614        let dir = TempDir::new().unwrap();
615        let server_id = ServerId::from("rust");
616        let caps = lsp_types::ServerCapabilities {
617            call_hierarchy_provider: Some(lsp_types::CallHierarchyProvider::Bool(true)),
618            ..Default::default()
619        };
620        let (translator, mut server) = translator_with_capabilities_and_encoding(
621            &dir,
622            &server_id,
623            caps,
624            lsp_types::PositionEncodingKind::UTF8,
625        );
626
627        let queried_path = dir.path().join("queried.rs");
628        fs::write(&queried_path, "aöb").unwrap();
629        let queried_uri = Url::from_file_path(&queried_path).unwrap().to_string();
630
631        let callee_path = dir.path().join("callee.rs");
632        fs::write(&callee_path, "abc").unwrap();
633        let callee_uri = Url::from_file_path(&callee_path).unwrap().to_string();
634
635        let item = CallHierarchyItemResult {
636            name: "queried_fn".to_string(),
637            kind: 12,
638            detail: None,
639            uri: queried_uri,
640            range: Range {
641                start: Position2D {
642                    line: 1,
643                    character: 1,
644                },
645                end: Position2D {
646                    line: 1,
647                    character: 4,
648                },
649            },
650            selection_range: Range {
651                start: Position2D {
652                    line: 1,
653                    character: 1,
654                },
655                end: Position2D {
656                    line: 1,
657                    character: 4,
658                },
659            },
660            data: None,
661        };
662
663        let translator = Arc::new(translator);
664        let handle = {
665            let translator = Arc::clone(&translator);
666            let item = serde_json::to_value(item).unwrap();
667            tokio::spawn(async move { translator.handle_outgoing_calls(item).await })
668        };
669
670        let mut wire = BufReader::new(&mut server.write_stdout);
671        let request = read_framed_message(&mut wire).await;
672        assert_eq!(request["method"], "callHierarchy/outgoingCalls");
673
674        write_response(
675            &mut server.read_half_stdin,
676            &request["id"],
677            serde_json::json!([{
678                "to": {
679                    "name": "callee_fn",
680                    "kind": 12,
681                    "uri": callee_uri,
682                    "range": {
683                        "start": {"line": 0, "character": 0},
684                        "end": {"line": 0, "character": 1}
685                    },
686                    "selectionRange": {
687                        "start": {"line": 0, "character": 0},
688                        "end": {"line": 0, "character": 1}
689                    }
690                },
691                "fromRanges": [{
692                    "start": {"line": 0, "character": 0},
693                    "end": {"line": 0, "character": 3}
694                }]
695            }]),
696        )
697        .await;
698
699        let result = timeout(Duration::from_secs(2), handle)
700            .await
701            .expect("handler call should not hang")
702            .unwrap()
703            .unwrap();
704
705        assert_eq!(result.calls.len(), 1);
706        let from_range = &result.calls[0].from_ranges[0];
707        assert_eq!(
708            from_range.end.character, 3,
709            "fromRanges must convert against the queried item's own file (\"aöb\"), not the \
710             callee's (\"abc\") -- a byte offset of 3 is UTF-16 column 3 in the former, 4 in \
711             the latter"
712        );
713    }
714}