1use 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
18fn 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
27pub(super) const MAX_TRIGGER_CHARACTER_BYTES: usize = 8;
37
38fn 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 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 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 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 #[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 #[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 #[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 #[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}