Skip to main content

mcpls_core/bridge/translator/
call_hierarchy.rs

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