ts_bridge/protocol/workspace/
execute_command.rs1use std::collections::{HashMap, VecDeque};
11
12use anyhow::{Context, Result};
13use lsp_types::{
14 ExecuteCommandParams, FileRename, GotoDefinitionParams, TextDocumentIdentifier,
15 TextDocumentPositionParams, Uri, WorkspaceEdit,
16};
17use serde::{Deserialize, Serialize};
18use serde_json::{Value, json};
19
20use crate::protocol::text_document::code_action::workspace_edit_from_tsserver_changes;
21use crate::protocol::text_document::definition::{self, DefinitionContext, DefinitionParams};
22use crate::protocol::{AdapterResult, RequestSpec};
23use crate::rpc::{Priority, Route};
24use crate::utils::{tsserver_span_to_location, uri_to_file_path};
25
26const ORGANIZE_MODE_ALL: &str = "All";
27const ORGANIZE_MODE_SORT_AND_COMBINE: &str = "SortAndCombine";
28const ORGANIZE_MODE_REMOVE_UNUSED: &str = "RemoveUnused";
29
30const FIX_UNUSED_IDENTIFIER: &str = "unusedIdentifier_delete";
31const FIX_MISSING_IMPORT: &str = "fixMissingImport";
32const FIX_ALL_CHAIN: &[&str] = &[
33 "fixClassIncorrectlyImplementsInterface",
34 "fixAwaitInSyncFunction",
35 "fixUnreachableCode",
36];
37
38pub const USER_COMMANDS: &[&str] = &[
39 "TSBOrganizeImports",
40 "TSBSortImports",
41 "TSBRemoveUnusedImports",
42 "TSBRemoveUnused",
43 "TSBAddMissingImports",
44 "TSBFixAll",
45 "TSBGoToSourceDefinition",
46 "TSBRenameFile",
47 "TSBFileReferences",
48 "TSBRestartProject",
49];
50
51pub fn handle(params: ExecuteCommandParams) -> Option<RequestSpec> {
52 let args = params.arguments;
53 match params.command.as_str() {
54 "TSBOrganizeImports" => organize_imports_command(&args, ORGANIZE_MODE_ALL),
55 "TSBSortImports" => organize_imports_command(&args, ORGANIZE_MODE_SORT_AND_COMBINE),
56 "TSBRemoveUnusedImports" => organize_imports_command(&args, ORGANIZE_MODE_REMOVE_UNUSED),
57 "TSBRemoveUnused" => combined_code_fix_command(&args, FIX_UNUSED_IDENTIFIER),
58 "TSBAddMissingImports" => combined_code_fix_command(&args, FIX_MISSING_IMPORT),
59 "TSBFixAll" => fix_all_command(&args),
60 "TSBGoToSourceDefinition" => goto_source_definition_command(&args),
61 "TSBRenameFile" => rename_file_command(&args),
62 "TSBFileReferences" => file_references_command(&args),
63 _ => None,
64 }
65}
66
67fn organize_imports_command(args: &[Value], mode: &str) -> Option<RequestSpec> {
68 let target = parse_file_target(args)?;
69 let request = json!({
70 "command": "organizeImports",
71 "arguments": {
72 "scope": {
73 "type": "file",
74 "args": { "file": target.file },
75 },
76 "mode": mode,
77 }
78 });
79 Some(RequestSpec {
80 route: Route::Syntax,
81 payload: request,
82 priority: Priority::Low,
83 on_response: Some(adapt_file_code_edits),
84 response_context: None,
85 })
86}
87
88fn combined_code_fix_command(args: &[Value], fix_id: &str) -> Option<RequestSpec> {
89 let target = parse_file_target(args)?;
90 Some(RequestSpec {
91 route: Route::Syntax,
92 payload: combined_code_fix_payload(&target.file, fix_id),
93 priority: Priority::Low,
94 on_response: Some(adapt_combined_code_fix),
95 response_context: None,
96 })
97}
98
99fn fix_all_command(args: &[Value]) -> Option<RequestSpec> {
100 let target = parse_file_target(args)?;
101 let mut pending: VecDeque<String> = FIX_ALL_CHAIN.iter().map(|id| id.to_string()).collect();
102 let current = pending.pop_front()?;
103 let context = serde_json::to_value(FixAllContext {
104 file: target.file.clone(),
105 pending_fix_ids: pending,
106 accumulated: None,
107 })
108 .ok()?;
109
110 Some(RequestSpec {
111 route: Route::Syntax,
112 payload: combined_code_fix_payload(&target.file, ¤t),
113 priority: Priority::Low,
114 on_response: Some(adapt_fix_all_chain),
115 response_context: Some(context),
116 })
117}
118
119fn goto_source_definition_command(args: &[Value]) -> Option<RequestSpec> {
120 let params: TextDocumentPositionParams = serde_json::from_value(args.first()?.clone()).ok()?;
121 let goto = GotoDefinitionParams {
122 text_document_position_params: params,
123 work_done_progress_params: Default::default(),
124 partial_result_params: Default::default(),
125 };
126 let spec = definition::handle(DefinitionParams {
127 base: goto,
128 context: Some(DefinitionContext {
129 source_definition: Some(true),
130 }),
131 });
132 Some(spec)
133}
134
135fn rename_file_command(args: &[Value]) -> Option<RequestSpec> {
136 let (old_path, new_path) = parse_rename_paths(args)?;
137 let request = json!({
138 "command": "getEditsForFileRename",
139 "arguments": {
140 "oldFilePath": old_path,
141 "newFilePath": new_path,
142 }
143 });
144
145 Some(RequestSpec {
146 route: Route::Syntax,
147 payload: request,
148 priority: Priority::Low,
149 on_response: Some(adapt_file_code_edits),
150 response_context: None,
151 })
152}
153
154fn file_references_command(args: &[Value]) -> Option<RequestSpec> {
155 let target = parse_file_target(args)?;
156 let request = json!({
157 "command": "fileReferences",
158 "arguments": { "file": target.file },
159 });
160
161 Some(RequestSpec {
162 route: Route::Syntax,
163 payload: request,
164 priority: Priority::Normal,
165 on_response: Some(adapt_file_references),
166 response_context: None,
167 })
168}
169
170fn combined_code_fix_payload(file: &str, fix_id: &str) -> Value {
171 json!({
172 "command": "getCombinedCodeFix",
173 "arguments": {
174 "scope": {
175 "type": "file",
176 "args": { "file": file },
177 },
178 "fixId": fix_id,
179 }
180 })
181}
182
183fn adapt_file_code_edits(payload: &Value, _context: Option<&Value>) -> Result<AdapterResult> {
184 let changes = payload
185 .get("body")
186 .and_then(|value| value.as_array())
187 .cloned()
188 .unwrap_or_default();
189 let edit = workspace_edit_from_tsserver_changes(&changes).unwrap_or_else(empty_workspace_edit);
190 Ok(AdapterResult::ready(serde_json::to_value(edit)?))
191}
192
193fn adapt_combined_code_fix(payload: &Value, _context: Option<&Value>) -> Result<AdapterResult> {
194 let combined = payload
195 .get("body")
196 .and_then(|body| {
197 body.get("changes")
198 .or_else(|| body.get("FileChanges"))
199 .and_then(|value| value.as_array())
200 })
201 .cloned()
202 .unwrap_or_default();
203 let edit = workspace_edit_from_tsserver_changes(&combined).unwrap_or_else(empty_workspace_edit);
204 Ok(AdapterResult::ready(serde_json::to_value(edit)?))
205}
206
207fn adapt_file_references(payload: &Value, _context: Option<&Value>) -> Result<AdapterResult> {
208 let refs = payload
209 .get("body")
210 .and_then(|body| body.get("refs"))
211 .and_then(|value| value.as_array())
212 .cloned()
213 .unwrap_or_default();
214
215 let mut locations = Vec::new();
216 for span in refs {
217 if let Some(location) = tsserver_span_to_location(&span) {
218 locations.push(location);
219 }
220 }
221
222 Ok(AdapterResult::ready(serde_json::to_value(locations)?))
223}
224
225fn adapt_fix_all_chain(payload: &Value, context: Option<&Value>) -> Result<AdapterResult> {
226 let mut state: FixAllContext =
227 serde_json::from_value(context.cloned().context("missing fixAll context")?)?;
228 let combined = payload
229 .get("body")
230 .and_then(|body| {
231 body.get("changes")
232 .or_else(|| body.get("FileChanges"))
233 .and_then(|value| value.as_array())
234 })
235 .cloned()
236 .unwrap_or_default();
237 if let Some(edit) = workspace_edit_from_tsserver_changes(&combined) {
238 state.merge_edit(edit);
239 }
240
241 if let Some(next_fix) = state.pending_fix_ids.pop_front() {
242 let updated_context = serde_json::to_value(&state)?;
243 return Ok(AdapterResult::Continue(RequestSpec {
244 route: Route::Syntax,
245 payload: combined_code_fix_payload(&state.file, &next_fix),
246 priority: Priority::Low,
247 on_response: Some(adapt_fix_all_chain),
248 response_context: Some(updated_context),
249 }));
250 }
251
252 let result = state.accumulated.unwrap_or_else(empty_workspace_edit);
253 Ok(AdapterResult::ready(serde_json::to_value(result)?))
254}
255
256fn empty_workspace_edit() -> WorkspaceEdit {
257 WorkspaceEdit {
258 changes: Some(HashMap::new()),
259 document_changes: None,
260 change_annotations: None,
261 }
262}
263
264fn parse_file_target(args: &[Value]) -> Option<CommandTarget> {
265 let uri = extract_uri(args.first()?)?;
266 let file = uri_to_file_path(uri.as_str()).unwrap_or_else(|| uri.to_string());
267 Some(CommandTarget { uri, file })
268}
269
270fn extract_uri(value: &Value) -> Option<Uri> {
271 if let Some(obj) = value.as_object() {
272 if let Some(text_document) = obj.get("textDocument") {
273 if let Ok(id) = serde_json::from_value::<TextDocumentIdentifier>(text_document.clone())
274 {
275 return Some(id.uri);
276 }
277 }
278 if let Some(uri_value) = obj.get("uri").and_then(|v| v.as_str()) {
279 return uri_value.parse().ok();
280 }
281 }
282 if let Ok(id) = serde_json::from_value::<TextDocumentIdentifier>(value.clone()) {
283 return Some(id.uri);
284 }
285 if let Some(uri_str) = value.as_str() {
286 return uri_str.parse().ok();
287 }
288 None
289}
290
291fn parse_rename_paths(args: &[Value]) -> Option<(String, String)> {
292 let first = args.first()?.clone();
293 if let Ok(rename) = serde_json::from_value::<FileRename>(first.clone()) {
294 return Some((
295 uri_to_file_path(rename.old_uri.as_str()).unwrap_or_else(|| rename.old_uri.to_string()),
296 uri_to_file_path(rename.new_uri.as_str()).unwrap_or_else(|| rename.new_uri.to_string()),
297 ));
298 }
299 if let Some(files) = first.get("files").and_then(|v| v.as_array()) {
300 if let Some(entry) = files.first() {
301 if let Ok(rename) = serde_json::from_value::<FileRename>(entry.clone()) {
302 return Some((
303 uri_to_file_path(rename.old_uri.as_str())
304 .unwrap_or_else(|| rename.old_uri.to_string()),
305 uri_to_file_path(rename.new_uri.as_str())
306 .unwrap_or_else(|| rename.new_uri.to_string()),
307 ));
308 }
309 }
310 }
311 if let Ok(args) = serde_json::from_value::<RenameArgs>(first) {
312 let old_path = uri_to_file_path(&args.old_uri).unwrap_or_else(|| args.old_uri.clone());
313 let new_path = uri_to_file_path(&args.new_uri).unwrap_or_else(|| args.new_uri.clone());
314 return Some((old_path, new_path));
315 }
316 None
317}
318
319fn merge_workspace_edits(target: &mut WorkspaceEdit, source: WorkspaceEdit) {
320 let target_changes = target.changes.get_or_insert_with(HashMap::new);
321 if let Some(changes) = source.changes {
322 for (uri, mut edits) in changes.into_iter() {
323 target_changes.entry(uri).or_default().append(&mut edits);
324 }
325 }
326}
327
328#[derive(Debug)]
329struct CommandTarget {
330 #[allow(dead_code)]
331 uri: Uri,
332 file: String,
333}
334
335#[derive(Deserialize)]
336struct RenameArgs {
337 #[serde(alias = "oldUri")]
338 old_uri: String,
339 #[serde(alias = "newUri")]
340 new_uri: String,
341}
342
343#[derive(Debug, Clone, Serialize, Deserialize)]
344struct FixAllContext {
345 file: String,
346 #[serde(default)]
347 pending_fix_ids: VecDeque<String>,
348 #[serde(default)]
349 accumulated: Option<WorkspaceEdit>,
350}
351
352impl FixAllContext {
353 fn merge_edit(&mut self, edit: WorkspaceEdit) {
354 match &mut self.accumulated {
355 Some(accumulated) => merge_workspace_edits(accumulated, edit),
356 None => self.accumulated = Some(edit),
357 }
358 }
359}