ts_bridge/protocol/workspace/
execute_command.rs

1//! =============================================================================
2//! workspace/executeCommand
3//! =============================================================================
4//!
5//! Mirrors the user-facing commands provided by typescript-tools so Neovim user
6//! commands (e.g. :TSBOrganizeImports) can be wired through
7//! `workspace/executeCommand`. Each command maps to a concrete tsserver request
8//! and returns ready-to-apply workspace edits or locations.
9
10use 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, &current),
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}