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, InlayHintLabel, InlayHintParams, PartialResultParams,
5    SignatureHelpParams as LspSignatureHelpParams, TextDocumentIdentifier,
6    TextDocumentPositionParams, WorkDoneProgressParams,
7};
8
9use super::Translator;
10use super::dto::{
11    Completion, CompletionsResult, InlayHintEntry, InlayHintsResult, SignatureHelpResult,
12    SignatureInfo, SignatureParameter,
13};
14use crate::config::ToolKind;
15use crate::error::{Error, Result};
16
17/// Extract hover contents as markdown string.
18/// Convert LSP `Documentation` to a plain string.
19fn extract_documentation(doc: lsp_types::Documentation) -> String {
20    match doc {
21        lsp_types::Documentation::String(s) => s,
22        lsp_types::Documentation::MarkupContent(m) => m.value,
23    }
24}
25
26/// Maximum length, in bytes, of a `get_completions` `trigger` parameter.
27///
28/// The LSP spec defines `triggerCharacter` as a single character, but
29/// `CompletionsParams.trigger` is still an unbounded free-form `String`
30/// forwarded to the LSP server as `trigger_character` with no cap of its
31/// own (#309 M3) -- the same forwarding-without-a-cap shape `new_name` and
32/// `query` had. 8 bytes comfortably covers any single Unicode codepoint (at
33/// most 4 bytes in UTF-8) with margin, while still rejecting anything that
34/// isn't plausibly "one character".
35pub(super) const MAX_TRIGGER_CHARACTER_BYTES: usize = 8;
36
37/// Validate parameters for `handle_completions`.
38fn validate_completions_params(trigger: Option<&str>) -> Result<()> {
39    if let Some(trigger) = trigger
40        && trigger.len() > MAX_TRIGGER_CHARACTER_BYTES
41    {
42        return Err(Error::InvalidToolParams(format!(
43            "trigger too long: {} bytes (max {MAX_TRIGGER_CHARACTER_BYTES})",
44            trigger.len()
45        )));
46    }
47    Ok(())
48}
49
50impl Translator {
51    /// Handle completions request.
52    ///
53    /// # Errors
54    ///
55    /// Returns an error if `trigger` exceeds the maximum allowed length,
56    /// the LSP request fails, the file cannot be opened, or the routed
57    /// server does not advertise `completionProvider` support.
58    pub async fn handle_completions(
59        &self,
60        file_path: String,
61        line: u32,
62        character: u32,
63        trigger: Option<String>,
64    ) -> Result<CompletionsResult> {
65        validate_completions_params(trigger.as_deref())?;
66
67        let (server_id, client, uri) = self
68            .prepare_gated_document(
69                &file_path,
70                ToolKind::Completions,
71                "completionProvider",
72                |caps| caps.completion_provider.is_some(),
73            )
74            .await?;
75        let lsp_position = self
76            .encoding_ctx(&server_id)
77            .to_lsp(&uri, line, character)
78            .await;
79
80        let context = trigger.map(|trigger_char| lsp_types::CompletionContext {
81            trigger_kind: CompletionTriggerKind::TRIGGER_CHARACTER,
82            trigger_character: Some(trigger_char),
83        });
84
85        let params = CompletionParams {
86            text_document_position: TextDocumentPositionParams {
87                text_document: TextDocumentIdentifier { uri },
88                position: lsp_position,
89            },
90            work_done_progress_params: WorkDoneProgressParams::default(),
91            partial_result_params: PartialResultParams::default(),
92            context,
93        };
94
95        let response: Option<lsp_types::CompletionResponse> = client
96            .request(
97                "textDocument/completion",
98                params,
99                client.completion_timeout(),
100            )
101            .await?;
102
103        let items = match response {
104            Some(lsp_types::CompletionResponse::Array(items)) => items,
105            Some(lsp_types::CompletionResponse::List(list)) => list.items,
106            None => vec![],
107        };
108
109        let result = CompletionsResult {
110            items: items
111                .into_iter()
112                .map(|item| Completion {
113                    label: item.label,
114                    kind: item.kind.map(|k| format!("{k:?}")),
115                    detail: item.detail,
116                    documentation: item.documentation.map(|doc| match doc {
117                        lsp_types::Documentation::String(s) => s,
118                        lsp_types::Documentation::MarkupContent(m) => m.value,
119                    }),
120                })
121                .collect(),
122        };
123
124        Ok(result)
125    }
126
127    /// Handle signature help request (`textDocument/signatureHelp`).
128    ///
129    /// Returns parameter signatures and documentation while typing a function call.
130    /// `context` is omitted (None) — the server infers trigger state from position.
131    ///
132    /// # Errors
133    ///
134    /// Returns an error if the LSP request fails, the file cannot be opened,
135    /// or the routed server does not advertise `signatureHelpProvider` support.
136    pub async fn handle_signature_help(
137        &self,
138        file_path: String,
139        line: u32,
140        character: u32,
141    ) -> Result<SignatureHelpResult> {
142        let (server_id, client, uri) = self
143            .prepare_gated_document(
144                &file_path,
145                ToolKind::SignatureHelp,
146                "signatureHelpProvider",
147                |caps| caps.signature_help_provider.is_some(),
148            )
149            .await?;
150        let lsp_position = self
151            .encoding_ctx(&server_id)
152            .to_lsp(&uri, line, character)
153            .await;
154
155        let params = LspSignatureHelpParams {
156            text_document_position_params: TextDocumentPositionParams {
157                text_document: TextDocumentIdentifier { uri },
158                position: lsp_position,
159            },
160            work_done_progress_params: WorkDoneProgressParams::default(),
161            context: None,
162        };
163
164        let response: Option<lsp_types::SignatureHelp> = client
165            .request(
166                "textDocument/signatureHelp",
167                params,
168                client.request_timeout(),
169            )
170            .await?;
171
172        let result = match response {
173            Some(sig_help) => SignatureHelpResult {
174                signatures: sig_help
175                    .signatures
176                    .into_iter()
177                    .map(|sig| SignatureInfo {
178                        label: sig.label,
179                        documentation: sig.documentation.map(extract_documentation),
180                        parameters: sig
181                            .parameters
182                            .unwrap_or_default()
183                            .into_iter()
184                            .map(|p| SignatureParameter {
185                                label: match p.label {
186                                    lsp_types::ParameterLabel::Simple(s) => s,
187                                    lsp_types::ParameterLabel::LabelOffsets([start, end]) => {
188                                        format!("[{start},{end}]")
189                                    }
190                                },
191                                documentation: p.documentation.map(extract_documentation),
192                            })
193                            .collect(),
194                    })
195                    .collect(),
196                active_signature: sig_help.active_signature,
197                active_parameter: sig_help.active_parameter,
198            },
199            None => SignatureHelpResult {
200                signatures: vec![],
201                active_signature: None,
202                active_parameter: None,
203            },
204        };
205
206        Ok(result)
207    }
208
209    /// Handle inlay hints request (`textDocument/inlayHint`).
210    ///
211    /// Returns inferred type and parameter annotations the editor would render inline.
212    /// Output positions are in MCP 1-based form.
213    ///
214    /// # Errors
215    ///
216    /// Returns an error if the LSP request fails, the file cannot be opened,
217    /// or the routed server does not advertise `inlayHintProvider` support.
218    pub async fn handle_inlay_hints(
219        &self,
220        file_path: String,
221        start_line: u32,
222        start_character: u32,
223        end_line: u32,
224        end_character: u32,
225    ) -> Result<InlayHintsResult> {
226        let (server_id, client, uri) = self
227            .prepare_gated_document(
228                &file_path,
229                ToolKind::InlayHints,
230                "inlayHintProvider",
231                |caps| {
232                    matches!(
233                        caps.inlay_hint_provider,
234                        Some(lsp_types::OneOf::Left(true) | lsp_types::OneOf::Right(_))
235                    )
236                },
237            )
238            .await?;
239        let ctx = self.encoding_ctx(&server_id);
240        let response_uri = uri.clone();
241
242        let lsp_start = ctx.to_lsp(&uri, start_line, start_character).await;
243        let lsp_end = ctx.to_lsp(&uri, end_line, end_character).await;
244
245        let params = InlayHintParams {
246            text_document: TextDocumentIdentifier { uri },
247            range: lsp_types::Range {
248                start: lsp_start,
249                end: lsp_end,
250            },
251            work_done_progress_params: WorkDoneProgressParams::default(),
252        };
253
254        let response: Option<Vec<lsp_types::InlayHint>> = client
255            .request("textDocument/inlayHint", params, client.request_timeout())
256            .await?;
257
258        let mut hints = Vec::new();
259        for hint in response.unwrap_or_default() {
260            let position = ctx.to_mcp(&response_uri, hint.position).await;
261            let label = match hint.label {
262                InlayHintLabel::String(s) => s,
263                InlayHintLabel::LabelParts(parts) => parts
264                    .into_iter()
265                    .map(|p| p.value)
266                    .collect::<Vec<_>>()
267                    .concat(),
268            };
269            let tooltip = hint.tooltip.map(|t| match t {
270                lsp_types::InlayHintTooltip::String(s) => s,
271                lsp_types::InlayHintTooltip::MarkupContent(m) => m.value,
272            });
273            hints.push(InlayHintEntry {
274                position,
275                label,
276                kind: hint.kind.and_then(|k| {
277                    serde_json::to_value(k)
278                        .ok()
279                        .and_then(|v| v.as_i64())
280                        .and_then(|n| u8::try_from(n).ok())
281                }),
282                padding_left: hint.padding_left,
283                padding_right: hint.padding_right,
284                tooltip,
285            });
286        }
287
288        Ok(InlayHintsResult { hints })
289    }
290}
291
292#[cfg(test)]
293#[allow(clippy::unwrap_used, clippy::expect_used)]
294mod tests {
295    use super::*;
296
297    /// #309 M3: `trigger` has no cap of its own even though the LSP spec
298    /// defines it as a single character.
299    #[test]
300    fn test_validate_completions_params_rejects_oversized_trigger() {
301        let trigger = "a".repeat(MAX_TRIGGER_CHARACTER_BYTES + 1);
302        let result = validate_completions_params(Some(&trigger));
303        assert!(matches!(result, Err(Error::InvalidToolParams(_))));
304    }
305
306    #[test]
307    fn test_validate_completions_params_accepts_typical_trigger_char() {
308        assert!(validate_completions_params(Some(".")).is_ok());
309    }
310
311    #[test]
312    fn test_validate_completions_params_accepts_none() {
313        assert!(validate_completions_params(None).is_ok());
314    }
315}