ts_bridge/protocol/text_document/
completion_resolve.rs

1//! =============================================================================
2//! completionItem/resolve
3//! =============================================================================
4//!
5//! Enriches completion items by calling tsserver’s `completionEntryDetails`
6//! command. The handler expects items produced by our completion adapter so it
7//! can reuse the stored metadata (`data.file`, `data.position`,
8//! `data.entryNames`).
9
10use anyhow::{Context, Result};
11use lsp_types::{
12    CompletionItem, CompletionItemKind, CompletionTextEdit, Documentation, InsertTextFormat,
13    MarkupContent, MarkupKind, Position, TextEdit,
14};
15use serde::Deserialize;
16use serde_json::{Value, json};
17
18use crate::protocol::{AdapterResult, RequestSpec};
19use crate::rpc::{Priority, Route};
20use crate::utils::tsserver_range_from_value_lsp;
21
22#[derive(Debug, Deserialize)]
23#[serde(rename_all = "camelCase")]
24struct CompletionResolveData {
25    file: String,
26    position: Position,
27    #[serde(default)]
28    entry_names: Vec<Value>,
29}
30
31pub fn handle(mut item: CompletionItem) -> Option<RequestSpec> {
32    let data = item.data.take()?;
33    let data: CompletionResolveData = serde_json::from_value(data).ok()?;
34    let request = json!({
35        "command": "completionEntryDetails",
36        "arguments": {
37            "file": data.file,
38            "line": data.position.line + 1,
39            "offset": data.position.character + 1,
40            "entryNames": data.entry_names,
41        }
42    });
43
44    let context = serde_json::to_value(item).ok()?;
45
46    Some(RequestSpec {
47        route: Route::Syntax,
48        payload: request,
49        priority: Priority::Normal,
50        on_response: Some(adapt_completion_resolve),
51        response_context: Some(context),
52    })
53}
54
55fn adapt_completion_resolve(payload: &Value, context: Option<&Value>) -> Result<AdapterResult> {
56    let mut item: CompletionItem =
57        serde_json::from_value(context.cloned().context("missing completion item")?)?;
58    let details = payload
59        .get("body")
60        .and_then(|value| value.as_array())
61        .and_then(|array| array.first())
62        .context("tsserver completion details missing body")?;
63
64    if let Some(display) = render_display_parts(details.get("displayParts")) {
65        item.detail = Some(display);
66    }
67
68    if let Some(documentation) = render_documentation(details) {
69        item.documentation = Some(Documentation::MarkupContent(MarkupContent {
70            kind: MarkupKind::Markdown,
71            value: documentation,
72        }));
73    }
74
75    if let Some(edits) = build_additional_text_edits(details.get("codeActions")) {
76        item.additional_text_edits = Some(edits);
77    }
78
79    if should_create_function_snippet(&item, details) {
80        inject_snippet(&mut item, details);
81    }
82
83    Ok(AdapterResult::ready(serde_json::to_value(item)?))
84}
85
86fn render_display_parts(parts: Option<&Value>) -> Option<String> {
87    let parts = parts?.as_array()?;
88    let mut buffer = String::new();
89    for part in parts {
90        if let Some(text) = part.get("text").and_then(|v| v.as_str()) {
91            buffer.push_str(text);
92        }
93    }
94    if buffer.is_empty() {
95        None
96    } else {
97        Some(buffer)
98    }
99}
100
101fn render_documentation(details: &Value) -> Option<String> {
102    let mut sections = Vec::new();
103    if let Some(docs) = details
104        .get("documentation")
105        .and_then(|value| value.as_array())
106    {
107        let mut buffer = String::new();
108        for entry in docs {
109            if let Some(text) = entry.get("text").and_then(|v| v.as_str()) {
110                if !buffer.is_empty() {
111                    buffer.push('\n');
112                }
113                buffer.push_str(text);
114            }
115        }
116        if !buffer.is_empty() {
117            sections.push(buffer);
118        }
119    }
120    if let Some(tags) = details.get("tags").and_then(|value| value.as_array()) {
121        for tag in tags {
122            if let Some(name) = tag.get("name").and_then(|v| v.as_str()) {
123                let text = tag
124                    .get("text")
125                    .and_then(|t| render_display_parts(Some(t)))
126                    .unwrap_or_default();
127                if text.is_empty() {
128                    sections.push(format!("_@{}_", name));
129                } else {
130                    sections.push(format!("_@{}_ — {}", name, text));
131                }
132            }
133        }
134    }
135    if sections.is_empty() {
136        None
137    } else {
138        Some(sections.join("\n\n"))
139    }
140}
141
142fn build_additional_text_edits(actions_value: Option<&Value>) -> Option<Vec<TextEdit>> {
143    let actions = actions_value?.as_array()?;
144    let mut edits = Vec::new();
145    for action in actions {
146        let changes = action.get("changes").and_then(|value| value.as_array());
147        if let Some(changes) = changes {
148            for change in changes {
149                if let Some(text_changes) = change.get("textChanges").and_then(|v| v.as_array()) {
150                    for text_change in text_changes {
151                        if let Some(range) = tsserver_range_from_value_lsp(text_change) {
152                            if let Some(new_text) =
153                                text_change.get("newText").and_then(|v| v.as_str())
154                            {
155                                edits.push(TextEdit {
156                                    range,
157                                    new_text: new_text.to_string(),
158                                });
159                            }
160                        }
161                    }
162                }
163            }
164        }
165    }
166    if edits.is_empty() { None } else { Some(edits) }
167}
168
169fn should_create_function_snippet(item: &CompletionItem, details: &Value) -> bool {
170    matches!(
171        item.kind.unwrap_or(CompletionItemKind::TEXT),
172        CompletionItemKind::FUNCTION | CompletionItemKind::METHOD | CompletionItemKind::CONSTRUCTOR
173    ) && details.get("displayParts").is_some()
174}
175
176fn inject_snippet(item: &mut CompletionItem, details: &Value) {
177    if let Some(parts) = details
178        .get("displayParts")
179        .and_then(|value| value.as_array())
180    {
181        let mut snippet = String::new();
182        snippet.push_str(
183            item.insert_text
184                .as_deref()
185                .or_else(|| {
186                    item.text_edit.as_ref().map(|edit| match edit {
187                        CompletionTextEdit::Edit(edit) => edit.new_text.as_str(),
188                        CompletionTextEdit::InsertAndReplace(edit) => edit.new_text.as_str(),
189                    })
190                })
191                .unwrap_or(item.label.as_str()),
192        );
193        snippet.push('(');
194
195        let mut param_index = 1;
196        let mut first = true;
197        for part in parts {
198            let kind = part.get("kind").and_then(|v| v.as_str());
199            if kind == Some("parameterName") {
200                if let Some(text) = part.get("text").and_then(|v| v.as_str()) {
201                    if !first {
202                        snippet.push_str(", ");
203                    }
204                    snippet.push_str(&format!("${{{}:{}}}", param_index, escape_snippet(text)));
205                    param_index += 1;
206                    first = false;
207                }
208            } else if kind == Some("punctuation") {
209                if let Some(text) = part.get("text").and_then(|v| v.as_str()) {
210                    if text == ")" {
211                        break;
212                    }
213                }
214            }
215        }
216
217        if first {
218            snippet.push_str("$0");
219        }
220        snippet.push(')');
221
222        item.insert_text = Some(snippet.clone());
223        item.insert_text_format = Some(InsertTextFormat::SNIPPET);
224        if let Some(CompletionTextEdit::Edit(edit)) = item.text_edit.as_mut() {
225            edit.new_text = snippet;
226        }
227    }
228}
229
230fn escape_snippet(value: &str) -> String {
231    value
232        .replace('\\', "\\\\")
233        .replace('$', "\\$")
234        .replace('}', "\\}")
235}