ts_bridge/protocol/text_document/
completion.rs

1//! =============================================================================
2//! textDocument/completion
3//! =============================================================================
4//!
5//! Bridges LSP completion requests to tsserver’s `completionInfo` command and
6//! reshapes the entries into `CompletionList` items.
7
8use anyhow::{Context, Result};
9use lsp_types::{
10    CompletionItem, CompletionItemTag, CompletionList, CompletionParams, CompletionResponse,
11    CompletionTextEdit, InsertTextFormat, Position, TextEdit,
12};
13use serde_json::{Value, json};
14
15use crate::protocol::{AdapterResult, RequestSpec};
16use crate::rpc::{Priority, Route};
17use crate::utils::{
18    completion_commit_characters, completion_item_kind_from_tsserver,
19    tsserver_range_from_value_lsp, uri_to_file_path,
20};
21
22pub const TRIGGER_CHARACTERS: &[&str] = &[".", "\"", "'", "`", "/", "@", "<", "#", " "];
23
24pub fn handle(params: CompletionParams) -> RequestSpec {
25    let CompletionParams {
26        text_document_position,
27        work_done_progress_params: _,
28        partial_result_params: _,
29        context,
30    } = params;
31    let text_document = text_document_position.text_document;
32    let position = text_document_position.position;
33    let uri_string = text_document.uri.to_string();
34    let file_name = uri_to_file_path(text_document.uri.as_str()).unwrap_or(uri_string);
35
36    let trigger_kind = context.as_ref().map(|ctx| ctx.trigger_kind);
37    let trigger_character = context
38        .as_ref()
39        .and_then(|ctx| ctx.trigger_character.clone())
40        .filter(|ch| TRIGGER_CHARACTERS.contains(&ch.as_str()));
41
42    let mut arguments = json!({
43        "file": file_name,
44        "line": position.line + 1,
45        "offset": position.character + 1,
46        "includeExternalModuleExports": true,
47        "includeInsertTextCompletions": true,
48    });
49    if let Some(kind) = trigger_kind {
50        arguments
51            .as_object_mut()
52            .unwrap()
53            .insert("triggerKind".into(), json!(kind));
54    }
55    if let Some(character) = trigger_character {
56        arguments
57            .as_object_mut()
58            .unwrap()
59            .insert("triggerCharacter".into(), json!(character));
60    }
61
62    let request = json!({
63        "command": "completionInfo",
64        "arguments": arguments,
65    });
66
67    RequestSpec {
68        route: Route::Syntax,
69        payload: request,
70        priority: Priority::Normal,
71        on_response: Some(adapt_completion),
72        response_context: Some(json!({
73            "file": file_name,
74            "position": {
75                "line": position.line,
76                "character": position.character,
77            }
78        })),
79    }
80}
81
82fn adapt_completion(payload: &Value, context: Option<&Value>) -> Result<AdapterResult> {
83    let ctx = context.context("completion context missing")?;
84    let file = ctx
85        .get("file")
86        .and_then(|v| v.as_str())
87        .context("completion context missing file")?;
88    let position: Position = ctx
89        .get("position")
90        .cloned()
91        .map(|value| serde_json::from_value(value))
92        .transpose()?
93        .unwrap_or(Position {
94            line: 0,
95            character: 0,
96        });
97
98    let body = payload
99        .get("body")
100        .context("tsserver completion missing body")?;
101    let entries = body
102        .get("entries")
103        .and_then(|value| value.as_array())
104        .cloned()
105        .unwrap_or_default();
106    let is_incomplete = body
107        .get("isIncomplete")
108        .and_then(|value| value.as_bool())
109        .unwrap_or(false);
110
111    let mut items = Vec::with_capacity(entries.len());
112    for entry in entries {
113        if let Some(item) = convert_entry(&entry, file, &position) {
114            items.push(item);
115        }
116    }
117
118    let list = CompletionList {
119        is_incomplete,
120        items,
121    };
122    Ok(AdapterResult::ready(serde_json::to_value(
123        CompletionResponse::List(list),
124    )?))
125}
126
127fn convert_entry(entry: &Value, file: &str, position: &Position) -> Option<CompletionItem> {
128    let name = entry.get("name")?.as_str()?.to_string();
129    let mut label = name.clone();
130    let kind_modifiers = entry.get("kindModifiers").and_then(|v| v.as_str());
131    if is_optional(kind_modifiers) {
132        label.push('?');
133    }
134
135    let insert_text = entry
136        .get("insertText")
137        .and_then(|v| v.as_str())
138        .unwrap_or(&name)
139        .to_string();
140
141    let kind = completion_item_kind_from_tsserver(entry.get("kind").and_then(|v| v.as_str()));
142
143    let mut item = CompletionItem {
144        label,
145        kind: Some(kind),
146        sort_text: entry
147            .get("sortText")
148            .and_then(|v| v.as_str())
149            .map(|s| s.to_string()),
150        filter_text: Some(insert_text.clone()),
151        insert_text: Some(insert_text.clone()),
152        insert_text_format: Some(
153            if entry
154                .get("isSnippet")
155                .and_then(|v| v.as_bool())
156                .unwrap_or(false)
157            {
158                InsertTextFormat::SNIPPET
159            } else {
160                InsertTextFormat::PLAIN_TEXT
161            },
162        ),
163        ..CompletionItem::default()
164    };
165
166    if let Some(range_value) = entry.get("replacementSpan") {
167        if let Some(range) = tsserver_range_from_value_lsp(range_value) {
168            item.text_edit = Some(CompletionTextEdit::Edit(TextEdit {
169                range,
170                new_text: insert_text.clone(),
171            }));
172        }
173    }
174
175    if is_deprecated(kind_modifiers) {
176        item.tags = Some(vec![CompletionItemTag::DEPRECATED]);
177    }
178
179    if let Some(chars) = completion_commit_characters(kind) {
180        item.commit_characters = Some(chars);
181    }
182
183    if entry
184        .get("hasAction")
185        .and_then(|v| v.as_bool())
186        .unwrap_or(false)
187        && entry.get("source").is_some()
188    {
189        let sort = item
190            .sort_text
191            .clone()
192            .unwrap_or_else(|| insert_text.clone());
193        item.sort_text = Some(format!("\u{FFFF}{}", sort));
194    }
195
196    item.data = Some(json!({
197        "file": file,
198        "position": {
199            "line": position.line,
200            "character": position.character,
201        },
202        "entryNames": [build_entry_name(entry, &name)],
203    }));
204
205    Some(item)
206}
207
208fn is_deprecated(modifiers: Option<&str>) -> bool {
209    modifiers
210        .map(|mods| mods.contains("deprecated"))
211        .unwrap_or(false)
212}
213
214fn is_optional(modifiers: Option<&str>) -> bool {
215    modifiers
216        .map(|mods| mods.contains("optional"))
217        .unwrap_or(false)
218}
219
220fn build_entry_name(entry: &Value, name: &str) -> Value {
221    let mut map = serde_json::Map::new();
222    map.insert("name".to_string(), json!(name));
223    if let Some(source) = entry.get("source") {
224        map.insert("source".to_string(), source.clone());
225    }
226    if let Some(data) = entry.get("data") {
227        map.insert("data".to_string(), data.clone());
228    }
229    Value::Object(map)
230}