ts_bridge/protocol/text_document/
completion.rs1use 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}