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