Skip to main content

mcpls_core/bridge/translator/
assist.rs

1//! Completions, signature help, and inlay hints handlers.
2
3use lsp_types::{
4    CompletionParams, CompletionTriggerKind, InlayHintParams, PartialResultParams,
5    SignatureHelpParams as LspSignatureHelpParams, TextDocumentIdentifier,
6    TextDocumentPositionParams, WorkDoneProgressParams,
7};
8
9use super::Translator;
10use super::dto::{
11    Completion, CompletionsResult, InlayHintEntry, InlayHintsResult, Position, SignatureHelpResult,
12    SignatureInfo, SignatureParameter, lsp_kind_to_u32,
13};
14use super::routing::{Capability, IndexingGate};
15use crate::config::ToolKind;
16use crate::error::{Error, Result};
17
18/// Extract hover contents as markdown string.
19/// Convert LSP `Documentation` to a plain string.
20fn extract_documentation(doc: lsp_types::Documentation) -> String {
21    match doc {
22        lsp_types::Documentation::String(s) => s,
23        lsp_types::Documentation::MarkupContent(m) => m.value,
24    }
25}
26
27/// Maximum length, in bytes, of a `get_completions` `trigger` parameter.
28///
29/// The LSP spec defines `triggerCharacter` as a single character, but
30/// `CompletionsParams.trigger` is still an unbounded free-form `String`
31/// forwarded to the LSP server as `trigger_character` with no cap of its
32/// own (#309 M3) -- the same forwarding-without-a-cap shape `new_name` and
33/// `query` had. 8 bytes comfortably covers any single Unicode codepoint (at
34/// most 4 bytes in UTF-8) with margin, while still rejecting anything that
35/// isn't plausibly "one character".
36pub(super) const MAX_TRIGGER_CHARACTER_BYTES: usize = 8;
37
38/// Validate parameters for `handle_completions`.
39fn validate_completions_params(trigger: Option<&str>) -> Result<()> {
40    if let Some(trigger) = trigger
41        && trigger.len() > MAX_TRIGGER_CHARACTER_BYTES
42    {
43        return Err(Error::InvalidToolParams(format!(
44            "trigger too long: {} bytes (max {MAX_TRIGGER_CHARACTER_BYTES})",
45            trigger.len()
46        )));
47    }
48    Ok(())
49}
50
51impl Translator {
52    /// Handle completions request.
53    ///
54    /// # Errors
55    ///
56    /// Returns an error if `trigger` exceeds the maximum allowed length,
57    /// the LSP request fails, the file cannot be opened, the routed server
58    /// does not advertise `completionProvider` support, or the server is
59    /// still indexing the workspace (see `wait_for_indexing_ready`).
60    pub async fn handle_completions(
61        &self,
62        file_path: String,
63        position: Position,
64        trigger: Option<String>,
65    ) -> Result<CompletionsResult> {
66        let Position { line, character } = position;
67        validate_completions_params(trigger.as_deref())?;
68
69        let (server_id, client, uri) = self
70            .prepare_gated_document(
71                &file_path,
72                ToolKind::Completions,
73                Capability::Completions,
74                IndexingGate::Required,
75            )
76            .await?;
77        let lsp_position = self
78            .encoding_ctx(&server_id)
79            .to_lsp(&uri, line, character)
80            .await;
81
82        let context = trigger.map(|trigger_char| lsp_types::CompletionContext {
83            trigger_kind: CompletionTriggerKind::TriggerCharacter,
84            trigger_character: Some(trigger_char),
85        });
86
87        let params = CompletionParams {
88            text_document_position_params: TextDocumentPositionParams {
89                text_document: TextDocumentIdentifier { uri },
90                position: lsp_position,
91            },
92            work_done_progress_params: WorkDoneProgressParams::default(),
93            partial_result_params: PartialResultParams::default(),
94            context,
95        };
96
97        let response = client
98            .request_typed::<lsp_types::CompletionRequest>(params, client.completion_timeout())
99            .await?;
100
101        let items = match response {
102            Some(lsp_types::CompletionResponse::CompletionItemList(items)) => items,
103            Some(lsp_types::CompletionResponse::CompletionList(list)) => list.items,
104            None => vec![],
105        };
106
107        let result = CompletionsResult {
108            items: items
109                .into_iter()
110                .map(|item| Completion {
111                    label: item.label,
112                    kind: item.kind.map(lsp_kind_to_u32),
113                    detail: item.detail,
114                    documentation: item.documentation.map(|doc| match doc {
115                        lsp_types::Documentation::String(s) => s,
116                        lsp_types::Documentation::MarkupContent(m) => m.value,
117                    }),
118                })
119                .collect(),
120        };
121
122        Ok(result)
123    }
124
125    /// Handle signature help request (`textDocument/signatureHelp`).
126    ///
127    /// Returns parameter signatures and documentation while typing a function call.
128    /// `context` is omitted (None) — the server infers trigger state from position.
129    ///
130    /// # Errors
131    ///
132    /// Returns an error if the LSP request fails, the file cannot be opened,
133    /// or the routed server does not advertise `signatureHelpProvider` support.
134    pub async fn handle_signature_help(
135        &self,
136        file_path: String,
137        position: Position,
138    ) -> Result<SignatureHelpResult> {
139        let Position { line, character } = position;
140        let (server_id, client, uri) = self
141            .prepare_gated_document(
142                &file_path,
143                ToolKind::SignatureHelp,
144                Capability::SignatureHelp,
145                IndexingGate::NotRequired,
146            )
147            .await?;
148        let lsp_position = self
149            .encoding_ctx(&server_id)
150            .to_lsp(&uri, line, character)
151            .await;
152
153        let params = LspSignatureHelpParams {
154            text_document_position_params: TextDocumentPositionParams {
155                text_document: TextDocumentIdentifier { uri },
156                position: lsp_position,
157            },
158            work_done_progress_params: WorkDoneProgressParams::default(),
159            context: None,
160        };
161
162        let response = client
163            .request_typed::<lsp_types::SignatureHelpRequest>(params, client.request_timeout())
164            .await?;
165
166        let result = match response {
167            Some(sig_help) => SignatureHelpResult {
168                signatures: sig_help
169                    .signatures
170                    .into_iter()
171                    .map(|sig| SignatureInfo {
172                        label: sig.label,
173                        documentation: sig.documentation.map(extract_documentation),
174                        parameters: sig
175                            .parameters
176                            .unwrap_or_default()
177                            .into_iter()
178                            .map(|p| SignatureParameter {
179                                label: match p.label {
180                                    lsp_types::ParameterInformationLabel::String(s) => s,
181                                    lsp_types::ParameterInformationLabel::Tuple((start, end)) => {
182                                        format!("[{start},{end}]")
183                                    }
184                                },
185                                documentation: p.documentation.map(extract_documentation),
186                            })
187                            .collect(),
188                    })
189                    .collect(),
190                active_signature: sig_help.active_signature,
191                active_parameter: sig_help.active_parameter.and_then(|ap| match ap {
192                    lsp_types::ActiveParameter::Int(n) => Some(n),
193                    lsp_types::ActiveParameter::Null => None,
194                }),
195            },
196            None => SignatureHelpResult {
197                signatures: vec![],
198                active_signature: None,
199                active_parameter: None,
200            },
201        };
202
203        Ok(result)
204    }
205
206    /// Handle inlay hints request (`textDocument/inlayHint`).
207    ///
208    /// Returns inferred type and parameter annotations the editor would render inline.
209    /// Output positions are in MCP 1-based form.
210    ///
211    /// # Errors
212    ///
213    /// Returns an error if the LSP request fails, the file cannot be opened,
214    /// or the routed server does not advertise `inlayHintProvider` support.
215    pub async fn handle_inlay_hints(
216        &self,
217        file_path: String,
218        start: Position,
219        end: Position,
220    ) -> Result<InlayHintsResult> {
221        let (server_id, client, uri) = self
222            .prepare_gated_document(
223                &file_path,
224                ToolKind::InlayHints,
225                Capability::InlayHints,
226                IndexingGate::NotRequired,
227            )
228            .await?;
229        let ctx = self.encoding_ctx(&server_id);
230        let response_uri = uri.clone();
231
232        let lsp_start = ctx.to_lsp(&uri, start.line, start.character).await;
233        let lsp_end = ctx.to_lsp(&uri, end.line, end.character).await;
234
235        let params = InlayHintParams {
236            text_document: TextDocumentIdentifier { uri },
237            range: lsp_types::Range {
238                start: lsp_start,
239                end: lsp_end,
240            },
241            work_done_progress_params: WorkDoneProgressParams::default(),
242        };
243
244        let response = client
245            .request_typed::<lsp_types::InlayHintRequest>(params, client.request_timeout())
246            .await?;
247
248        let mut hints = Vec::new();
249        for hint in response.unwrap_or_default() {
250            let position = ctx.to_mcp(&response_uri, hint.position).await;
251            let label = match hint.label {
252                lsp_types::Label::String(s) => s,
253                lsp_types::Label::InlayHintLabelPartList(parts) => parts
254                    .into_iter()
255                    .map(|p| p.value)
256                    .collect::<Vec<_>>()
257                    .concat(),
258            };
259            let tooltip = hint.tooltip.map(|t| match t {
260                lsp_types::Tooltip::String(s) => s,
261                lsp_types::Tooltip::MarkupContent(m) => m.value,
262            });
263            hints.push(InlayHintEntry {
264                position,
265                label,
266                kind: hint.kind.map(lsp_kind_to_u32),
267                padding_left: hint.padding_left,
268                padding_right: hint.padding_right,
269                tooltip,
270            });
271        }
272
273        Ok(InlayHintsResult {
274            hints,
275            positions_degraded: ctx.positions_degraded(),
276        })
277    }
278}
279
280#[cfg(test)]
281#[allow(clippy::unwrap_used, clippy::expect_used)]
282mod tests {
283    use std::fs;
284
285    use super::*;
286    use crate::bridge::translator::testing::*;
287
288    /// #309 M3: `trigger` has no cap of its own even though the LSP spec
289    /// defines it as a single character.
290    #[test]
291    fn test_validate_completions_params_rejects_oversized_trigger() {
292        let trigger = "a".repeat(MAX_TRIGGER_CHARACTER_BYTES + 1);
293        let result = validate_completions_params(Some(&trigger));
294        assert!(matches!(result, Err(Error::InvalidToolParams(_))));
295    }
296
297    #[test]
298    fn test_validate_completions_params_accepts_typical_trigger_char() {
299        assert!(validate_completions_params(Some(".")).is_ok());
300    }
301
302    #[test]
303    fn test_validate_completions_params_accepts_none() {
304        assert!(validate_completions_params(None).is_ok());
305    }
306
307    /// End-to-end: `handle_completions` must surface
308    /// `Error::WorkspaceIndexing` -- not an empty result -- while the routed
309    /// server is still `Loading`, without reaching the fake LSP server.
310    #[tokio::test(start_paused = true)]
311    async fn test_handle_completions_returns_workspace_indexing_error_when_loading() {
312        use std::sync::Arc;
313
314        use tempfile::TempDir;
315        use tokio::sync::Mutex;
316
317        use crate::bridge::NotificationCache;
318        use crate::config::ServerId;
319
320        let dir = TempDir::new().unwrap();
321        let server_id = ServerId::from("rust");
322        let caps = lsp_types::ServerCapabilities {
323            completion_provider: Some(lsp_types::CompletionOptions::default()),
324            ..Default::default()
325        };
326        let (translator, _server) = translator_with_capabilities(&dir, &server_id, caps);
327
328        let cache = Arc::new(Mutex::new(NotificationCache::new()));
329        cache.lock().await.observe_indexing_signal(
330            &server_id,
331            "experimental/serverStatus",
332            Some(&serde_json::json!({"quiescent": false})),
333        );
334        let translator = translator.with_notification_cache(cache);
335
336        let path = dir.path().join("main.rs");
337        fs::write(&path, "fn main() {}").unwrap();
338
339        let err = translator
340            .handle_completions(
341                path.to_string_lossy().to_string(),
342                Position {
343                    line: 1,
344                    character: 1,
345                },
346                None,
347            )
348            .await
349            .unwrap_err();
350
351        assert!(matches!(
352            err,
353            Error::WorkspaceIndexing { server_id: id, .. } if id == server_id
354        ));
355    }
356
357    /// Companion: when the cache reports `Ready`, `handle_completions` must
358    /// dispatch normally.
359    #[tokio::test]
360    async fn test_handle_completions_dispatches_when_indexing_ready() {
361        use std::sync::Arc;
362
363        use tempfile::TempDir;
364        use tokio::io::BufReader;
365        use tokio::sync::Mutex;
366
367        use crate::bridge::NotificationCache;
368        use crate::config::ServerId;
369
370        let dir = TempDir::new().unwrap();
371        let server_id = ServerId::from("rust");
372        let caps = lsp_types::ServerCapabilities {
373            completion_provider: Some(lsp_types::CompletionOptions::default()),
374            ..Default::default()
375        };
376        let (translator, mut server) = translator_with_capabilities(&dir, &server_id, caps);
377
378        let cache = Arc::new(Mutex::new(NotificationCache::new()));
379        cache.lock().await.observe_indexing_signal(
380            &server_id,
381            "experimental/serverStatus",
382            Some(&serde_json::json!({"quiescent": true})),
383        );
384        let translator = Arc::new(translator.with_notification_cache(cache));
385
386        let path = dir.path().join("main.rs");
387        fs::write(&path, "fn main() {}").unwrap();
388
389        let handle = {
390            let translator = Arc::clone(&translator);
391            let path = path.to_string_lossy().to_string();
392            tokio::spawn(async move {
393                translator
394                    .handle_completions(
395                        path,
396                        Position {
397                            line: 1,
398                            character: 1,
399                        },
400                        None,
401                    )
402                    .await
403            })
404        };
405
406        let mut wire = BufReader::new(&mut server.write_stdout);
407        let opened = read_framed_message(&mut wire).await;
408        assert_eq!(opened["method"], "textDocument/didOpen");
409        let request = read_framed_message(&mut wire).await;
410        assert_eq!(request["method"], "textDocument/completion");
411
412        write_response(
413            &mut server.read_half_stdin,
414            &request["id"],
415            serde_json::json!([]),
416        )
417        .await;
418
419        let result = handle.await.unwrap().unwrap();
420        assert!(result.items.is_empty());
421    }
422
423    /// #467 M2/tester gap 1: exercises the actual `handle_inlay_hints`
424    /// conversion site (`assist.rs:266`, not just the bare `lsp_kind_to_u32`
425    /// helper) with a `kind` value above `u8::MAX` -- the old `Option<u8>`
426    /// roundtrip silently dropped this to `None`.
427    #[tokio::test]
428    async fn test_handle_inlay_hints_preserves_custom_kind_above_u8_range() {
429        use std::sync::Arc;
430
431        use tempfile::TempDir;
432        use tokio::io::BufReader;
433
434        use crate::bridge::translator::testing::pos;
435        use crate::config::ServerId;
436
437        let dir = TempDir::new().unwrap();
438        let server_id = ServerId::from("rust");
439        let caps = lsp_types::ServerCapabilities {
440            inlay_hint_provider: Some(lsp_types::InlayHintProvider::Bool(true)),
441            ..Default::default()
442        };
443        let (translator, mut server) = translator_with_capabilities(&dir, &server_id, caps);
444
445        let path = dir.path().join("main.rs");
446        fs::write(&path, "fn main() {}\n").unwrap();
447
448        let translator = Arc::new(translator);
449        let handle = {
450            let translator = Arc::clone(&translator);
451            let path = path.to_string_lossy().to_string();
452            tokio::spawn(async move {
453                translator
454                    .handle_inlay_hints(path, pos(1, 1), pos(1, 13))
455                    .await
456            })
457        };
458
459        let mut wire = BufReader::new(&mut server.write_stdout);
460        let opened = read_framed_message(&mut wire).await;
461        assert_eq!(opened["method"], "textDocument/didOpen");
462        let request = read_framed_message(&mut wire).await;
463        assert_eq!(request["method"], "textDocument/inlayHint");
464
465        write_response(
466            &mut server.read_half_stdin,
467            &request["id"],
468            serde_json::json!([{
469                "position": {"line": 0, "character": 5},
470                "label": "custom",
471                "kind": 300,
472            }]),
473        )
474        .await;
475
476        let result = handle.await.unwrap().unwrap();
477        assert_eq!(result.hints.len(), 1);
478        assert_eq!(
479            result.hints[0].kind,
480            Some(300u32),
481            "a custom InlayHintKind above u8::MAX must not be truncated or dropped"
482        );
483    }
484}