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,
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        position: Position,
62        trigger: Option<String>,
63    ) -> Result<CompletionsResult> {
64        let Position { line, character } = position;
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::TriggerCharacter,
82            trigger_character: Some(trigger_char),
83        });
84
85        let params = CompletionParams {
86            text_document_position_params: 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 = client
96            .request_typed::<lsp_types::CompletionRequest>(params, client.completion_timeout())
97            .await?;
98
99        let items = match response {
100            Some(lsp_types::CompletionResponse::CompletionItemList(items)) => items,
101            Some(lsp_types::CompletionResponse::CompletionList(list)) => list.items,
102            None => vec![],
103        };
104
105        let result = CompletionsResult {
106            items: items
107                .into_iter()
108                .map(|item| Completion {
109                    label: item.label,
110                    kind: item.kind.map(|k| format!("{k:?}")),
111                    detail: item.detail,
112                    documentation: item.documentation.map(|doc| match doc {
113                        lsp_types::Documentation::String(s) => s,
114                        lsp_types::Documentation::MarkupContent(m) => m.value,
115                    }),
116                })
117                .collect(),
118        };
119
120        Ok(result)
121    }
122
123    /// Handle signature help request (`textDocument/signatureHelp`).
124    ///
125    /// Returns parameter signatures and documentation while typing a function call.
126    /// `context` is omitted (None) — the server infers trigger state from position.
127    ///
128    /// # Errors
129    ///
130    /// Returns an error if the LSP request fails, the file cannot be opened,
131    /// or the routed server does not advertise `signatureHelpProvider` support.
132    pub async fn handle_signature_help(
133        &self,
134        file_path: String,
135        position: Position,
136    ) -> Result<SignatureHelpResult> {
137        let Position { line, character } = position;
138        let (server_id, client, uri) = self
139            .prepare_gated_document(
140                &file_path,
141                ToolKind::SignatureHelp,
142                "signatureHelpProvider",
143                |caps| caps.signature_help_provider.is_some(),
144            )
145            .await?;
146        let lsp_position = self
147            .encoding_ctx(&server_id)
148            .to_lsp(&uri, line, character)
149            .await;
150
151        let params = LspSignatureHelpParams {
152            text_document_position_params: TextDocumentPositionParams {
153                text_document: TextDocumentIdentifier { uri },
154                position: lsp_position,
155            },
156            work_done_progress_params: WorkDoneProgressParams::default(),
157            context: None,
158        };
159
160        let response = client
161            .request_typed::<lsp_types::SignatureHelpRequest>(params, client.request_timeout())
162            .await?;
163
164        let result = match response {
165            Some(sig_help) => SignatureHelpResult {
166                signatures: sig_help
167                    .signatures
168                    .into_iter()
169                    .map(|sig| SignatureInfo {
170                        label: sig.label,
171                        documentation: sig.documentation.map(extract_documentation),
172                        parameters: sig
173                            .parameters
174                            .unwrap_or_default()
175                            .into_iter()
176                            .map(|p| SignatureParameter {
177                                label: match p.label {
178                                    lsp_types::ParameterInformationLabel::String(s) => s,
179                                    lsp_types::ParameterInformationLabel::Tuple((start, end)) => {
180                                        format!("[{start},{end}]")
181                                    }
182                                },
183                                documentation: p.documentation.map(extract_documentation),
184                            })
185                            .collect(),
186                    })
187                    .collect(),
188                active_signature: sig_help.active_signature,
189                active_parameter: sig_help.active_parameter.and_then(|ap| match ap {
190                    lsp_types::ActiveParameter::Int(n) => Some(n),
191                    lsp_types::ActiveParameter::Null => None,
192                }),
193            },
194            None => SignatureHelpResult {
195                signatures: vec![],
196                active_signature: None,
197                active_parameter: None,
198            },
199        };
200
201        Ok(result)
202    }
203
204    /// Handle inlay hints request (`textDocument/inlayHint`).
205    ///
206    /// Returns inferred type and parameter annotations the editor would render inline.
207    /// Output positions are in MCP 1-based form.
208    ///
209    /// # Errors
210    ///
211    /// Returns an error if the LSP request fails, the file cannot be opened,
212    /// or the routed server does not advertise `inlayHintProvider` support.
213    pub async fn handle_inlay_hints(
214        &self,
215        file_path: String,
216        start: Position,
217        end: Position,
218    ) -> Result<InlayHintsResult> {
219        let (server_id, client, uri) = self
220            .prepare_gated_document(
221                &file_path,
222                ToolKind::InlayHints,
223                "inlayHintProvider",
224                |caps| {
225                    matches!(
226                        caps.inlay_hint_provider,
227                        Some(
228                            lsp_types::InlayHintProvider::Bool(true)
229                                | lsp_types::InlayHintProvider::InlayHintOptions(_)
230                                | lsp_types::InlayHintProvider::InlayHintRegistrationOptions(_)
231                        )
232                    )
233                },
234            )
235            .await?;
236        let ctx = self.encoding_ctx(&server_id);
237        let response_uri = uri.clone();
238
239        let lsp_start = ctx.to_lsp(&uri, start.line, start.character).await;
240        let lsp_end = ctx.to_lsp(&uri, end.line, end.character).await;
241
242        let params = InlayHintParams {
243            text_document: TextDocumentIdentifier { uri },
244            range: lsp_types::Range {
245                start: lsp_start,
246                end: lsp_end,
247            },
248            work_done_progress_params: WorkDoneProgressParams::default(),
249        };
250
251        let response = client
252            .request_typed::<lsp_types::InlayHintRequest>(params, client.request_timeout())
253            .await?;
254
255        let mut hints = Vec::new();
256        for hint in response.unwrap_or_default() {
257            let position = ctx.to_mcp(&response_uri, hint.position).await;
258            let label = match hint.label {
259                lsp_types::Label::String(s) => s,
260                lsp_types::Label::InlayHintLabelPartList(parts) => parts
261                    .into_iter()
262                    .map(|p| p.value)
263                    .collect::<Vec<_>>()
264                    .concat(),
265            };
266            let tooltip = hint.tooltip.map(|t| match t {
267                lsp_types::Tooltip::String(s) => s,
268                lsp_types::Tooltip::MarkupContent(m) => m.value,
269            });
270            hints.push(InlayHintEntry {
271                position,
272                label,
273                kind: hint.kind.and_then(|k| {
274                    serde_json::to_value(k)
275                        .ok()
276                        .and_then(|v| v.as_i64())
277                        .and_then(|n| u8::try_from(n).ok())
278                }),
279                padding_left: hint.padding_left,
280                padding_right: hint.padding_right,
281                tooltip,
282            });
283        }
284
285        Ok(InlayHintsResult { hints })
286    }
287}
288
289#[cfg(test)]
290#[allow(clippy::unwrap_used, clippy::expect_used)]
291mod tests {
292    use super::*;
293
294    /// #309 M3: `trigger` has no cap of its own even though the LSP spec
295    /// defines it as a single character.
296    #[test]
297    fn test_validate_completions_params_rejects_oversized_trigger() {
298        let trigger = "a".repeat(MAX_TRIGGER_CHARACTER_BYTES + 1);
299        let result = validate_completions_params(Some(&trigger));
300        assert!(matches!(result, Err(Error::InvalidToolParams(_))));
301    }
302
303    #[test]
304    fn test_validate_completions_params_accepts_typical_trigger_char() {
305        assert!(validate_completions_params(Some(".")).is_ok());
306    }
307
308    #[test]
309    fn test_validate_completions_params_accepts_none() {
310        assert!(validate_completions_params(None).is_ok());
311    }
312}