Skip to main content

mcpls_core/bridge/translator/
assist.rs

1//! Completions, signature help, and inlay hints handlers.
2
3use lsp_types::CompletionTriggerKind;
4
5use super::Translator;
6use super::dto::{
7    Completion, CompletionTrigger, CompletionsResult, InlayHintEntry, InlayHintsResult, Position,
8    PositionRange, SignatureHelpResult, SignatureInfo, SignatureParameter, lsp_kind_to_u32,
9};
10use super::navigation::ItemBudget;
11use super::positioned::Positioned;
12use super::routing::{Capability, DisclosedCapability, IndexingGate};
13use crate::bridge::encoding::{LabelOffsets, PositionEncoding};
14use crate::bridge::{ClientPath, Indexed};
15use crate::error::Result;
16
17/// Convert LSP `Documentation` to a plain string.
18fn extract_documentation(doc: lsp_types::Documentation) -> String {
19    match doc {
20        lsp_types::Documentation::String(s) => s,
21        lsp_types::Documentation::MarkupContent(m) => m.value,
22    }
23}
24
25/// Most parameters kept per signature; the rest are dropped (logged).
26const MAX_SIGNATURE_PARAMETERS: usize = 256;
27
28/// Total resolved tuple-label bytes allowed per signature, as a multiple of
29/// the signature label's length: generous enough for overlapping or repeated
30/// spans (variadic or default-value ranges), bounded against amplification.
31const RESOLVED_LABEL_BUDGET_MULTIPLE: usize = 4;
32
33/// Converts a signature's LSP parameters to their MCP form, resolving
34/// offset-pair labels against `signature_label` in `encoding`'s code units.
35///
36/// An offset pair that cannot be resolved exactly yields `label: None`
37/// (logged) rather than a wrong or raw `[start,end]` label (#511). The
38/// requested offsets are resolved in one pass per signature, the parameter
39/// count is capped at [`MAX_SIGNATURE_PARAMETERS`], and the resolved bytes in
40/// total are capped at [`RESOLVED_LABEL_BUDGET_MULTIPLE`] times the label's
41/// length, so many overlapping pairs cannot amplify one response.
42fn signature_parameters(
43    params: Vec<lsp_types::ParameterInformation>,
44    signature_label: &str,
45    encoding: PositionEncoding,
46) -> Vec<SignatureParameter> {
47    if params.len() > MAX_SIGNATURE_PARAMETERS {
48        tracing::warn!(
49            reported = params.len(),
50            cap = MAX_SIGNATURE_PARAMETERS,
51            "signature parameter count exceeds the cap; truncating"
52        );
53    }
54    let offsets = LabelOffsets::new(
55        signature_label,
56        params
57            .iter()
58            .take(MAX_SIGNATURE_PARAMETERS)
59            .filter_map(|p| match p.label {
60                lsp_types::ParameterInformationLabel::Tuple((start, end)) => {
61                    Some(<[u32; 2]>::from((start, end)))
62                }
63                lsp_types::ParameterInformationLabel::String(_) => None,
64            })
65            .flatten(),
66        encoding,
67    );
68    let mut resolved_budget = signature_label
69        .len()
70        .saturating_mul(RESOLVED_LABEL_BUDGET_MULTIPLE);
71    params
72        .into_iter()
73        .take(MAX_SIGNATURE_PARAMETERS)
74        .map(|param| {
75            let label = match param.label {
76                lsp_types::ParameterInformationLabel::String(s) => Some(s),
77                lsp_types::ParameterInformationLabel::Tuple((start, end)) => {
78                    let resolved = offsets
79                        .substring(start, end)
80                        .filter(|text| text.len() <= resolved_budget);
81                    if let Some(text) = resolved {
82                        resolved_budget = resolved_budget.saturating_sub(text.len());
83                    } else {
84                        tracing::warn!(
85                            start,
86                            end,
87                            encoding = encoding.to_lsp(),
88                            "signature parameter label offsets do not resolve within the \
89                             signature label; omitting the label"
90                        );
91                    }
92                    resolved.map(str::to_string)
93                }
94            };
95            SignatureParameter {
96                label,
97                documentation: param.documentation.map(extract_documentation),
98            }
99        })
100        .collect()
101}
102
103impl Translator {
104    /// Handle completions request.
105    ///
106    /// # Errors
107    ///
108    /// Returns an error if the LSP request fails, the file cannot be opened, the routed server
109    /// does not advertise `completionProvider` support, or the server is
110    /// still indexing the workspace (see `wait_for_indexing_ready`).
111    pub async fn handle_completions(
112        &self,
113        file_path: ClientPath,
114        position: Position,
115        trigger: Option<CompletionTrigger>,
116    ) -> Result<CompletionsResult> {
117        let context = trigger.map(|trigger_char| lsp_types::CompletionContext {
118            trigger_kind: CompletionTriggerKind::TriggerCharacter,
119            trigger_character: Some(trigger_char.into_string()),
120        });
121
122        let Positioned {
123            result: response,
124            ctx,
125            doc: _doc,
126        } = self
127            .position_request::<lsp_types::CompletionRequest>(
128                &file_path,
129                position,
130                IndexingGate::Required(Capability::Completions),
131                context,
132            )
133            .await?;
134
135        let items = match response {
136            Some(lsp_types::CompletionResponse::CompletionItemList(items)) => items,
137            Some(lsp_types::CompletionResponse::CompletionList(list)) => list.items,
138            None => vec![],
139        };
140
141        let result = CompletionsResult {
142            items: items
143                .into_iter()
144                .map(|item| Completion {
145                    label: item.label,
146                    kind: item.kind.map(lsp_kind_to_u32),
147                    detail: item.detail,
148                    documentation: item.documentation.map(extract_documentation),
149                })
150                .collect(),
151            positions_degraded: ctx.positions_degraded(),
152        };
153
154        Ok(result)
155    }
156
157    /// Handle signature help request (`textDocument/signatureHelp`).
158    ///
159    /// Returns parameter signatures and documentation while typing a function call.
160    /// `context` is omitted (None) — the server infers trigger state from position.
161    ///
162    /// # Errors
163    ///
164    /// Returns an error if the LSP request fails, the file cannot be opened,
165    /// or the routed server does not advertise `signatureHelpProvider` support.
166    pub async fn handle_signature_help(
167        &self,
168        file_path: ClientPath,
169        position: Position,
170    ) -> Result<Indexed<SignatureHelpResult>> {
171        let Positioned {
172            result:
173                Indexed {
174                    result: response,
175                    indexing,
176                },
177            ctx,
178            doc: _doc,
179        } = self
180            .disclosed_position_request::<lsp_types::SignatureHelpRequest>(
181                &file_path,
182                position,
183                DisclosedCapability::SignatureHelp,
184                (),
185            )
186            .await?;
187
188        let result = match response {
189            Some(sig_help) => SignatureHelpResult {
190                signatures: sig_help
191                    .signatures
192                    .into_iter()
193                    .map(|sig| SignatureInfo {
194                        parameters: signature_parameters(
195                            sig.parameters.unwrap_or_default(),
196                            &sig.label,
197                            ctx.encoding,
198                        ),
199                        documentation: sig.documentation.map(extract_documentation),
200                        label: sig.label,
201                    })
202                    .collect(),
203                active_signature: sig_help.active_signature,
204                active_parameter: sig_help.active_parameter.and_then(|ap| match ap {
205                    lsp_types::ActiveParameter::Int(n) => Some(n),
206                    lsp_types::ActiveParameter::Null => None,
207                }),
208                positions_degraded: ctx.positions_degraded(),
209            },
210            None => SignatureHelpResult {
211                signatures: vec![],
212                active_signature: None,
213                active_parameter: None,
214                positions_degraded: ctx.positions_degraded(),
215            },
216        };
217
218        Ok(Indexed::new(result, indexing))
219    }
220
221    /// Handle inlay hints request (`textDocument/inlayHint`).
222    ///
223    /// Returns inferred type and parameter annotations the editor would render inline.
224    /// Output positions are in MCP 1-based form.
225    ///
226    /// # Errors
227    ///
228    /// Returns an error if the LSP request fails, the file cannot be opened,
229    /// or the routed server does not advertise `inlayHintProvider` support.
230    pub async fn handle_inlay_hints(
231        &self,
232        file_path: ClientPath,
233        range: PositionRange,
234    ) -> Result<Indexed<InlayHintsResult>> {
235        let Positioned {
236            result:
237                Indexed {
238                    result: response,
239                    indexing,
240                },
241            ctx,
242            doc,
243        } = self
244            .disclosed_range_request::<lsp_types::InlayHintRequest>(
245                &file_path,
246                DisclosedCapability::InlayHints,
247                range,
248                (),
249            )
250            .await?;
251        let uri = doc.uri();
252
253        let mut budget = ItemBudget::new();
254        let lsp_hints = budget.admit(response.unwrap_or_default());
255        let mut hints = Vec::with_capacity(lsp_hints.len());
256        for hint in lsp_hints {
257            let position = ctx.to_mcp(uri, hint.position).await;
258            let label = match hint.label {
259                lsp_types::Label::String(s) => s,
260                lsp_types::Label::InlayHintLabelPartList(parts) => {
261                    parts.into_iter().map(|p| p.value).collect::<String>()
262                }
263            };
264            let tooltip = hint.tooltip.map(|t| match t {
265                lsp_types::Tooltip::String(s) => s,
266                lsp_types::Tooltip::MarkupContent(m) => m.value,
267            });
268            hints.push(InlayHintEntry {
269                position,
270                label,
271                kind: hint.kind.map(lsp_kind_to_u32),
272                padding_left: hint.padding_left,
273                padding_right: hint.padding_right,
274                tooltip,
275            });
276        }
277
278        Ok(Indexed::new(
279            InlayHintsResult {
280                hints,
281                truncated: budget.truncated(),
282                positions_degraded: ctx.positions_degraded(),
283            },
284            indexing,
285        ))
286    }
287}
288
289#[cfg(test)]
290mod tests {
291    use std::{assert_matches, fs};
292
293    use super::*;
294    use crate::bridge::IndexingSignal;
295    use crate::bridge::translator::testing::*;
296    use crate::config::ServerId;
297    use crate::error::Error;
298    use crate::test_lsp::client_path;
299
300    /// End-to-end: `handle_completions` must surface
301    /// `Error::WorkspaceIndexing` -- not an empty result -- while the routed
302    /// server is still `Loading`, without reaching the fake LSP server.
303    #[tokio::test(start_paused = true)]
304    async fn test_handle_completions_returns_workspace_indexing_error_when_loading() {
305        use std::sync::Arc;
306
307        use tempfile::TempDir;
308        use tokio::sync::Mutex;
309
310        use crate::bridge::NotificationCache;
311        use crate::config::ServerId;
312
313        let dir = TempDir::new().unwrap();
314        let server_id = ServerId::from_static("rust");
315        let caps = lsp_types::ServerCapabilities {
316            completion_provider: Some(lsp_types::CompletionOptions::default()),
317            ..Default::default()
318        };
319        let (translator, _server) = translator_with_capabilities(&dir, &server_id, caps);
320
321        let cache = Arc::new(Mutex::new(NotificationCache::new()));
322        cache.lock().await.observe_indexing_signal(
323            &server_id,
324            "experimental/serverStatus",
325            Some(&serde_json::json!({"quiescent": false})),
326        );
327        let translator = translator.with_notification_cache(cache);
328
329        let path = dir.path().join("main.rs");
330        fs::write(&path, "fn main() {}").unwrap();
331
332        let err = translator
333            .handle_completions(
334                client_path(path.to_string_lossy().into_owned()),
335                Position::at(1, 1),
336                None,
337            )
338            .await
339            .unwrap_err();
340
341        assert_matches!(
342            err,
343            Error::WorkspaceIndexing { server_id: id, .. } if id == server_id
344        );
345    }
346
347    /// Companion: when the cache reports `Ready`, `handle_completions` must
348    /// dispatch normally.
349    #[tokio::test]
350    async fn test_handle_completions_dispatches_when_indexing_ready() {
351        use std::sync::Arc;
352
353        use tempfile::TempDir;
354        use tokio::io::BufReader;
355        use tokio::sync::Mutex;
356
357        use crate::bridge::NotificationCache;
358        use crate::config::ServerId;
359
360        let dir = TempDir::new().unwrap();
361        let server_id = ServerId::from_static("rust");
362        let caps = lsp_types::ServerCapabilities {
363            completion_provider: Some(lsp_types::CompletionOptions::default()),
364            ..Default::default()
365        };
366        let (translator, mut server) = translator_with_capabilities(&dir, &server_id, caps);
367
368        let cache = Arc::new(Mutex::new(NotificationCache::new()));
369        cache.lock().await.observe_indexing_signal(
370            &server_id,
371            "experimental/serverStatus",
372            Some(&serde_json::json!({"quiescent": true})),
373        );
374        let translator = Arc::new(translator.with_notification_cache(cache));
375
376        let path = dir.path().join("main.rs");
377        fs::write(&path, "fn main() {}").unwrap();
378
379        let handle = {
380            let translator = Arc::clone(&translator);
381            let path = path.to_string_lossy().to_string();
382            tokio::spawn(async move {
383                translator
384                    .handle_completions(client_path(path), Position::at(1, 1), None)
385                    .await
386            })
387        };
388
389        let mut wire = BufReader::new(&mut server.write_stdout);
390        let opened = read_framed_message(&mut wire).await;
391        assert_eq!(opened["method"], "textDocument/didOpen");
392        let request = read_framed_message(&mut wire).await;
393        assert_eq!(request["method"], "textDocument/completion");
394
395        write_response(
396            &mut server.read_half_stdin,
397            &request["id"],
398            serde_json::json!([]),
399        )
400        .await;
401
402        let result = handle.await.unwrap().unwrap();
403        assert!(result.items.is_empty());
404    }
405
406    /// #467 M2/tester gap 1: exercises the actual `handle_inlay_hints`
407    /// conversion site (`assist.rs:266`, not just the bare `lsp_kind_to_u32`
408    /// helper) with a `kind` value above `u8::MAX` -- the old `Option<u8>`
409    /// roundtrip silently dropped this to `None`.
410    #[tokio::test]
411    async fn test_handle_inlay_hints_preserves_custom_kind_above_u8_range() {
412        use std::sync::Arc;
413
414        use tempfile::TempDir;
415        use tokio::io::BufReader;
416
417        use crate::bridge::translator::testing::pos;
418        use crate::config::ServerId;
419
420        let dir = TempDir::new().unwrap();
421        let server_id = ServerId::from_static("rust");
422        let caps = lsp_types::ServerCapabilities {
423            inlay_hint_provider: Some(lsp_types::InlayHintProvider::Bool(true)),
424            ..Default::default()
425        };
426        let (translator, mut server) = translator_with_capabilities(&dir, &server_id, caps);
427
428        let path = dir.path().join("main.rs");
429        fs::write(&path, "fn main() {}\n").unwrap();
430
431        let translator = Arc::new(translator);
432        let handle = {
433            let translator = Arc::clone(&translator);
434            let path = path.to_string_lossy().to_string();
435            tokio::spawn(async move {
436                translator
437                    .handle_inlay_hints(client_path(path), span(pos(1, 1), pos(1, 13)))
438                    .await
439            })
440        };
441
442        let mut wire = BufReader::new(&mut server.write_stdout);
443        let opened = read_framed_message(&mut wire).await;
444        assert_eq!(opened["method"], "textDocument/didOpen");
445        let request = read_framed_message(&mut wire).await;
446        assert_eq!(request["method"], "textDocument/inlayHint");
447
448        write_response(
449            &mut server.read_half_stdin,
450            &request["id"],
451            serde_json::json!([{
452                "position": {"line": 0, "character": 5},
453                "label": "custom",
454                "kind": 300,
455            }]),
456        )
457        .await;
458
459        let result = handle.await.unwrap().unwrap().result;
460        assert_eq!(result.hints.len(), 1);
461        assert_eq!(
462            result.hints[0].kind,
463            Some(300u32),
464            "a custom InlayHintKind above u8::MAX must not be truncated or dropped"
465        );
466    }
467
468    async fn inlay_hints_with_response(count: usize) -> InlayHintsResult {
469        use std::sync::Arc;
470
471        use tempfile::TempDir;
472        use tokio::io::BufReader;
473
474        use crate::bridge::translator::testing::pos;
475        use crate::config::ServerId;
476
477        let dir = TempDir::new().unwrap();
478        let server_id = ServerId::from_static("rust");
479        let caps = lsp_types::ServerCapabilities {
480            inlay_hint_provider: Some(lsp_types::InlayHintProvider::Bool(true)),
481            ..Default::default()
482        };
483        let (translator, mut server) = translator_with_capabilities(&dir, &server_id, caps);
484
485        let path = dir.path().join("main.rs");
486        fs::write(&path, "fn main() {}\n").unwrap();
487
488        let translator = Arc::new(translator);
489        let handle = {
490            let translator = Arc::clone(&translator);
491            let path = path.to_string_lossy().to_string();
492            tokio::spawn(async move {
493                translator
494                    .handle_inlay_hints(client_path(path), span(pos(1, 1), pos(1, 13)))
495                    .await
496            })
497        };
498
499        let mut wire = BufReader::new(&mut server.write_stdout);
500        let opened = read_framed_message(&mut wire).await;
501        assert_eq!(opened["method"], "textDocument/didOpen");
502        let request = read_framed_message(&mut wire).await;
503        assert_eq!(request["method"], "textDocument/inlayHint");
504
505        let hints: Vec<_> = (0..count)
506            .map(|_| serde_json::json!({"position": {"line": 0, "character": 5}, "label": "h"}))
507            .collect();
508        write_response(
509            &mut server.read_half_stdin,
510            &request["id"],
511            serde_json::Value::Array(hints),
512        )
513        .await;
514
515        handle.await.unwrap().unwrap().result
516    }
517
518    /// #487: inlay hints beyond `MAX_NORMALIZED_LOCATIONS` are dropped and
519    /// reported via `truncated`.
520    #[tokio::test]
521    async fn test_handle_inlay_hints_caps_item_count_and_reports_truncation() {
522        use crate::bridge::translator::navigation::MAX_NORMALIZED_LOCATIONS;
523
524        let result = inlay_hints_with_response(MAX_NORMALIZED_LOCATIONS + 500).await;
525        assert_eq!(result.hints.len(), MAX_NORMALIZED_LOCATIONS);
526        assert!(result.truncated);
527    }
528
529    /// #487: exactly `MAX_NORMALIZED_LOCATIONS` hints is not truncation.
530    #[tokio::test]
531    async fn test_handle_inlay_hints_at_cap_is_not_truncated() {
532        use crate::bridge::translator::navigation::MAX_NORMALIZED_LOCATIONS;
533
534        let result = inlay_hints_with_response(MAX_NORMALIZED_LOCATIONS).await;
535        assert_eq!(result.hints.len(), MAX_NORMALIZED_LOCATIONS);
536        assert!(!result.truncated);
537    }
538
539    async fn utf8_degradation(
540        content: &str,
541        line: u32,
542        character: u32,
543        method: &str,
544    ) -> serde_json::Value {
545        use std::sync::Arc;
546
547        use tempfile::TempDir;
548        use tokio::io::BufReader;
549
550        use crate::config::ServerId;
551
552        let dir = TempDir::new().unwrap();
553        let server_id = ServerId::from_static("rust");
554        let caps = lsp_types::ServerCapabilities {
555            completion_provider: Some(lsp_types::CompletionOptions::default()),
556            signature_help_provider: Some(lsp_types::SignatureHelpOptions::default()),
557            ..Default::default()
558        };
559        let (translator, mut server) = translator_with_capabilities_and_encoding(
560            &dir,
561            &server_id,
562            caps,
563            lsp_types::PositionEncodingKind::UTF8,
564        );
565        let path = dir.path().join("main.rs");
566        fs::write(&path, content).unwrap();
567
568        let translator = Arc::new(translator);
569        let handle = {
570            let translator = Arc::clone(&translator);
571            let path = path.to_string_lossy().to_string();
572            let method = method.to_string();
573            tokio::spawn(async move {
574                if method == "textDocument/completion" {
575                    translator
576                        .handle_completions(client_path(path), pos(line, character), None)
577                        .await
578                        .map(|r| serde_json::to_value(r).unwrap())
579                } else {
580                    translator
581                        .handle_signature_help(client_path(path), pos(line, character))
582                        .await
583                        .map(|r| serde_json::to_value(r).unwrap())
584                }
585            })
586        };
587
588        let mut wire = BufReader::new(&mut server.write_stdout);
589        let opened = read_framed_message(&mut wire).await;
590        assert_eq!(opened["method"], "textDocument/didOpen");
591        let request = read_framed_message(&mut wire).await;
592        assert_eq!(request["method"], method);
593        let response = if method == "textDocument/completion" {
594            serde_json::json!([])
595        } else {
596            serde_json::Value::Null
597        };
598        write_response(&mut server.read_half_stdin, &request["id"], response).await;
599
600        handle.await.unwrap().unwrap()
601    }
602
603    /// S1 regression: the empty line after a final newline is a real LSP
604    /// line, so column 1 on it converts exactly and must not be flagged.
605    #[tokio::test]
606    async fn test_handle_completions_trailing_empty_line_is_not_degraded() {
607        let wire = utf8_degradation("fn main() {}\n", 2, 1, "textDocument/completion").await;
608        assert!(wire.get("positions_degraded").is_none());
609    }
610
611    /// #641: a line past the end is rejected before any request, so it can no
612    /// longer reach the request-degradation path.
613    #[tokio::test]
614    async fn test_handle_completions_and_signature_help_line_past_eof_are_rejected() {
615        use tempfile::TempDir;
616
617        use crate::config::ServerId;
618
619        let dir = TempDir::new().unwrap();
620        let caps = lsp_types::ServerCapabilities {
621            completion_provider: Some(lsp_types::CompletionOptions::default()),
622            signature_help_provider: Some(lsp_types::SignatureHelpOptions::default()),
623            ..Default::default()
624        };
625        let (translator, _server) = translator_with_capabilities_and_encoding(
626            &dir,
627            &ServerId::from_static("rust"),
628            caps,
629            lsp_types::PositionEncodingKind::UTF8,
630        );
631        let path = dir.path().join("main.rs");
632        fs::write(&path, "fn main() {}").unwrap();
633        let file = || client_path(path.to_string_lossy().into_owned());
634
635        let completions = translator.handle_completions(file(), pos(5, 3), None).await;
636        assert_matches!(completions, Err(Error::PositionBeyondDocument { .. }));
637        let signature = translator.handle_signature_help(file(), pos(5, 3)).await;
638        assert_matches!(signature, Err(Error::PositionBeyondDocument { .. }));
639    }
640
641    #[tokio::test]
642    async fn test_handle_signature_help_in_range_position_is_not_degraded() {
643        let wire = utf8_degradation("fn main() {}", 1, 4, "textDocument/signatureHelp").await;
644        assert!(wire.get("positions_degraded").is_none());
645    }
646
647    fn tuple_param(start: u32, end: u32) -> lsp_types::ParameterInformation {
648        lsp_types::ParameterInformation {
649            label: lsp_types::ParameterInformationLabel::Tuple((start, end)),
650            documentation: None,
651        }
652    }
653
654    fn one_param(
655        param: lsp_types::ParameterInformation,
656        label: &str,
657        encoding: PositionEncoding,
658    ) -> SignatureParameter {
659        signature_parameters(vec![param], label, encoding)
660            .pop()
661            .unwrap()
662    }
663
664    /// #511: tuple offsets are in the negotiated encoding's units, so the
665    /// same parameter resolves to the same substring under each of them.
666    #[test]
667    fn test_signature_parameter_tuple_label_resolves_per_encoding() {
668        let label = "é𝄞(a: u8)";
669        for (encoding, start, end) in [
670            (PositionEncoding::Utf8, 7, 12),
671            (PositionEncoding::Utf16, 4, 9),
672            (PositionEncoding::Utf32, 3, 8),
673        ] {
674            let param = one_param(tuple_param(start, end), label, encoding);
675            assert_eq!(param.label.as_deref(), Some("a: u8"), "{encoding:?}");
676        }
677    }
678
679    /// #511: offsets that do not resolve exactly yield no label rather than a
680    /// wrong substring or the raw pair.
681    #[test]
682    fn test_signature_parameter_invalid_tuple_offsets_yield_no_label() {
683        let label = "é𝄞(a: u8)";
684        for (encoding, start, end) in [
685            (PositionEncoding::Utf16, 0, 99),
686            (PositionEncoding::Utf16, 9, 4),
687            (PositionEncoding::Utf16, 2, 4),
688            (PositionEncoding::Utf8, 1, 3),
689            (PositionEncoding::Utf32, 3, 99),
690        ] {
691            let param = one_param(tuple_param(start, end), label, encoding);
692            assert_eq!(param.label, None, "{encoding:?} {start}..{end}");
693        }
694    }
695
696    #[test]
697    fn test_signature_parameter_string_label_passes_through() {
698        let param = one_param(
699            lsp_types::ParameterInformation {
700                label: lsp_types::ParameterInformationLabel::String("a: u8".to_string()),
701                documentation: None,
702            },
703            "f(a: u8)",
704            PositionEncoding::Utf16,
705        );
706        assert_eq!(param.label.as_deref(), Some("a: u8"));
707    }
708
709    #[test]
710    fn test_signature_parameter_unresolved_label_is_omitted_when_serialized() {
711        let param = one_param(tuple_param(5, 1), "abcdef", PositionEncoding::Utf16);
712        let wire = serde_json::to_value(param).unwrap();
713        assert!(wire.get("label").is_none());
714    }
715
716    /// Security L1: many parameters all spanning the whole label are capped in
717    /// count and in total resolved bytes.
718    #[test]
719    fn test_signature_parameters_whole_label_pairs_are_bounded() {
720        let label = "x".repeat(1000);
721        let params = vec![tuple_param(0, 1000); MAX_SIGNATURE_PARAMETERS * 4];
722        let out = signature_parameters(params, &label, PositionEncoding::Utf16);
723        assert_eq!(out.len(), MAX_SIGNATURE_PARAMETERS);
724        let resolved: usize = out
725            .iter()
726            .filter_map(|p| p.label.as_ref())
727            .map(String::len)
728            .sum();
729        assert!(
730            resolved <= label.len() * RESOLVED_LABEL_BUDGET_MULTIPLE,
731            "resolved {resolved} bytes"
732        );
733        assert_eq!(out[0].label.as_deref(), Some(label.as_str()));
734        assert!(out[RESOLVED_LABEL_BUDGET_MULTIPLE].label.is_none());
735    }
736
737    /// Overlapping and repeated spans within the budget all resolve.
738    #[test]
739    fn test_signature_parameters_overlapping_ranges_resolve() {
740        let label = "f(a, b, ...rest)";
741        let params = vec![
742            tuple_param(2, 3),
743            tuple_param(2, 3),
744            tuple_param(2, 15),
745            tuple_param(8, 15),
746        ];
747        let out = signature_parameters(params, label, PositionEncoding::Utf16);
748        let labels: Vec<_> = out.iter().map(|p| p.label.as_deref()).collect();
749        assert_eq!(
750            labels,
751            [Some("a"), Some("a"), Some("a, b, ...rest"), Some("...rest")]
752        );
753    }
754
755    /// Answers `null` to the one request `call` makes while the cache tracks
756    /// `quiescent`, and returns the indexing signal the handler reported.
757    async fn indexing_signal_of<T, Fut>(
758        caps: lsp_types::ServerCapabilities,
759        method: &str,
760        quiescent: Option<bool>,
761        call: impl FnOnce(std::sync::Arc<Translator>, String) -> Fut + Send + 'static,
762    ) -> IndexingSignal
763    where
764        T: Send + 'static,
765        Fut: Future<Output = Result<Indexed<T>>> + Send + 'static,
766    {
767        use std::sync::Arc;
768
769        use tempfile::TempDir;
770        use tokio::io::BufReader;
771        use tokio::sync::Mutex;
772
773        use crate::bridge::NotificationCache;
774
775        let dir = TempDir::new().unwrap();
776        let server_id = ServerId::from_static("rust");
777        let (translator, mut server) = translator_with_capabilities(&dir, &server_id, caps);
778
779        let cache = Arc::new(Mutex::new(NotificationCache::new()));
780        if let Some(quiescent) = quiescent {
781            cache.lock().await.observe_indexing_signal(
782                &server_id,
783                "experimental/serverStatus",
784                Some(&serde_json::json!({"quiescent": quiescent})),
785            );
786        }
787        let translator = Arc::new(translator.with_notification_cache(cache));
788
789        let path = dir.path().join("main.rs");
790        fs::write(&path, "fn main() {}\n").unwrap();
791        let path = path.to_string_lossy().into_owned();
792        let handle = tokio::spawn(call(Arc::clone(&translator), path));
793
794        let mut wire = BufReader::new(&mut server.write_stdout);
795        let opened = read_framed_message(&mut wire).await;
796        assert_eq!(opened["method"], "textDocument/didOpen");
797        let request = read_framed_message(&mut wire).await;
798        assert_eq!(request["method"], method);
799        write_response(
800            &mut server.read_half_stdin,
801            &request["id"],
802            serde_json::Value::Null,
803        )
804        .await;
805
806        handle.await.unwrap().unwrap().indexing
807    }
808
809    /// #668: every ungated name-resolving tool reports an index still loading,
810    /// and stays silent for a ready or unsignalled server (`Unknown` is not
811    /// evidence of indexing).
812    #[tokio::test]
813    async fn test_ungated_tools_report_indexing_only_while_loading() {
814        use crate::bridge::translator::testing::{pos, span};
815
816        let loading = IndexingSignal {
817            indexing_in_progress: true,
818        };
819        for (quiescent, expected) in [
820            (Some(false), loading),
821            (Some(true), IndexingSignal::default()),
822            (None, IndexingSignal::default()),
823        ] {
824            let call_hierarchy = lsp_types::ServerCapabilities {
825                call_hierarchy_provider: Some(lsp_types::CallHierarchyProvider::Bool(true)),
826                ..Default::default()
827            };
828            assert_eq!(
829                indexing_signal_of(
830                    call_hierarchy,
831                    "textDocument/prepareCallHierarchy",
832                    quiescent,
833                    |t, path| async move {
834                        t.handle_call_hierarchy_prepare(client_path(path), pos(1, 1))
835                            .await
836                    },
837                )
838                .await,
839                expected,
840                "prepare_call_hierarchy {quiescent:?}"
841            );
842
843            let type_hierarchy = lsp_types::ServerCapabilities {
844                type_hierarchy_provider: Some(lsp_types::TypeHierarchyProvider::Bool(true)),
845                ..Default::default()
846            };
847            assert_eq!(
848                indexing_signal_of(
849                    type_hierarchy,
850                    "textDocument/prepareTypeHierarchy",
851                    quiescent,
852                    |t, path| async move {
853                        t.handle_type_hierarchy_prepare(client_path(path), pos(1, 1))
854                            .await
855                    },
856                )
857                .await,
858                expected,
859                "prepare_type_hierarchy {quiescent:?}"
860            );
861
862            let signature_help = lsp_types::ServerCapabilities {
863                signature_help_provider: Some(lsp_types::SignatureHelpOptions::default()),
864                ..Default::default()
865            };
866            assert_eq!(
867                indexing_signal_of(
868                    signature_help,
869                    "textDocument/signatureHelp",
870                    quiescent,
871                    |t, path| async move {
872                        t.handle_signature_help(client_path(path), pos(1, 1)).await
873                    },
874                )
875                .await,
876                expected,
877                "get_signature_help {quiescent:?}"
878            );
879
880            let inlay_hints = lsp_types::ServerCapabilities {
881                inlay_hint_provider: Some(lsp_types::InlayHintProvider::Bool(true)),
882                ..Default::default()
883            };
884            assert_eq!(
885                indexing_signal_of(
886                    inlay_hints,
887                    "textDocument/inlayHint",
888                    quiescent,
889                    |t, path| async move {
890                        t.handle_inlay_hints(client_path(path), span(pos(1, 1), pos(1, 13)))
891                            .await
892                    },
893                )
894                .await,
895                expected,
896                "get_inlay_hints {quiescent:?}"
897            );
898        }
899    }
900}