diff --git a/cli/lsp/capabilities.rs b/cli/lsp/capabilities.rs index 89400d108d..a664c296d3 100644 --- a/cli/lsp/capabilities.rs +++ b/cli/lsp/capabilities.rs @@ -30,6 +30,7 @@ use lspower::lsp::WorkDoneProgressOptions; use lspower::lsp::WorkspaceFoldersServerCapabilities; use lspower::lsp::WorkspaceServerCapabilities; +use super::refactor::ALL_KNOWN_REFACTOR_ACTION_KINDS; use super::semantic_tokens::get_legend; fn code_action_capabilities( @@ -41,8 +42,16 @@ fn code_action_capabilities( .and_then(|it| it.code_action.as_ref()) .and_then(|it| it.code_action_literal_support.as_ref()) .map_or(CodeActionProviderCapability::Simple(true), |_| { + let mut code_action_kinds = + vec![CodeActionKind::QUICKFIX, CodeActionKind::REFACTOR]; + code_action_kinds.extend( + ALL_KNOWN_REFACTOR_ACTION_KINDS + .iter() + .map(|action| action.kind.clone()), + ); + CodeActionProviderCapability::Options(CodeActionOptions { - code_action_kinds: Some(vec![CodeActionKind::QUICKFIX]), + code_action_kinds: Some(code_action_kinds), resolve_provider: Some(true), work_done_progress_options: Default::default(), }) diff --git a/cli/lsp/language_server.rs b/cli/lsp/language_server.rs index 68ce9c58c3..8d672e2519 100644 --- a/cli/lsp/language_server.rs +++ b/cli/lsp/language_server.rs @@ -41,6 +41,7 @@ use super::documents::LanguageId; use super::lsp_custom; use super::parent_process_checker; use super::performance::Performance; +use super::refactor; use super::registries; use super::sources; use super::sources::Sources; @@ -1156,6 +1157,10 @@ impl Inner { } let mark = self.performance.mark("code_action", Some(¶ms)); + let mut all_actions = CodeActionResponse::new(); + let line_index = self.get_line_index_sync(&specifier).unwrap(); + + // QuickFix let fixable_diagnostics: Vec<&Diagnostic> = params .context .diagnostics @@ -1183,93 +1188,139 @@ impl Inner { None => false, }) .collect(); - if fixable_diagnostics.is_empty() { - self.performance.measure(mark); - return Ok(None); - } - let line_index = self.get_line_index_sync(&specifier).unwrap(); - let mut code_actions = CodeActionCollection::default(); - let file_diagnostics = self - .diagnostics_server - .get(&specifier, DiagnosticSource::TypeScript) - .await; - for diagnostic in &fixable_diagnostics { - match diagnostic.source.as_deref() { - Some("deno-ts") => { - let code = match diagnostic.code.as_ref().unwrap() { - NumberOrString::String(code) => code.to_string(), - NumberOrString::Number(code) => code.to_string(), - }; - let codes = vec![code]; - let req = tsc::RequestMethod::GetCodeFixes(( - specifier.clone(), - line_index.offset_tsc(diagnostic.range.start)?, - line_index.offset_tsc(diagnostic.range.end)?, - codes, - )); - let actions: Vec = - match self.ts_server.request(self.snapshot()?, req).await { - Ok(items) => items, - Err(err) => { - // sometimes tsc reports errors when retrieving code actions - // because they don't reflect the current state of the document - // so we will log them to the output, but we won't send an error - // message back to the client. - error!("Error getting actions from TypeScript: {}", err); - Vec::new() - } + if !fixable_diagnostics.is_empty() { + let mut code_actions = CodeActionCollection::default(); + let file_diagnostics = self + .diagnostics_server + .get(&specifier, DiagnosticSource::TypeScript) + .await; + for diagnostic in &fixable_diagnostics { + match diagnostic.source.as_deref() { + Some("deno-ts") => { + let code = match diagnostic.code.as_ref().unwrap() { + NumberOrString::String(code) => code.to_string(), + NumberOrString::Number(code) => code.to_string(), }; - for action in actions { - code_actions - .add_ts_fix_action(&specifier, &action, diagnostic, self) - .await - .map_err(|err| { - error!("Unable to convert fix: {}", err); - LspError::internal_error() - })?; - if code_actions.is_fix_all_action( - &action, - diagnostic, - &file_diagnostics, - ) { + let codes = vec![code]; + let req = tsc::RequestMethod::GetCodeFixes(( + specifier.clone(), + line_index.offset_tsc(diagnostic.range.start)?, + line_index.offset_tsc(diagnostic.range.end)?, + codes, + )); + let actions: Vec = + match self.ts_server.request(self.snapshot()?, req).await { + Ok(items) => items, + Err(err) => { + // sometimes tsc reports errors when retrieving code actions + // because they don't reflect the current state of the document + // so we will log them to the output, but we won't send an error + // message back to the client. + error!("Error getting actions from TypeScript: {}", err); + Vec::new() + } + }; + for action in actions { code_actions - .add_ts_fix_all_action(&action, &specifier, diagnostic); + .add_ts_fix_action(&specifier, &action, diagnostic, self) + .await + .map_err(|err| { + error!("Unable to convert fix: {}", err); + LspError::internal_error() + })?; + if code_actions.is_fix_all_action( + &action, + diagnostic, + &file_diagnostics, + ) { + code_actions + .add_ts_fix_all_action(&action, &specifier, diagnostic); + } } } - } - Some("deno") => { - code_actions + Some("deno") => code_actions .add_deno_fix_action(diagnostic) .map_err(|err| { error!("{}", err); LspError::internal_error() - })? + })?, + Some("deno-lint") => code_actions + .add_deno_lint_ignore_action( + &specifier, + self.documents.docs.get(&specifier), + diagnostic, + ) + .map_err(|err| { + error!("Unable to fix lint error: {}", err); + LspError::internal_error() + })?, + _ => (), } - Some("deno-lint") => code_actions - .add_deno_lint_ignore_action( - &specifier, - self.documents.docs.get(&specifier), - diagnostic, - ) - .map_err(|err| { - error!("Unable to fix lint error: {}", err); - LspError::internal_error() - })?, - _ => (), } + code_actions.set_preferred_fixes(); + all_actions.extend(code_actions.get_response()); } - code_actions.set_preferred_fixes(); - let code_action_response = code_actions.get_response(); + + // Refactor + let start = line_index.offset_tsc(params.range.start)?; + let length = line_index.offset_tsc(params.range.end)? - start; + let only = + params + .context + .only + .as_ref() + .map_or(String::default(), |values| { + values + .first() + .map_or(String::default(), |v| v.as_str().to_owned()) + }); + let req = tsc::RequestMethod::GetApplicableRefactors(( + specifier.clone(), + tsc::TextSpan { start, length }, + only, + )); + let refactor_infos: Vec = self + .ts_server + .request(self.snapshot()?, req) + .await + .map_err(|err| { + error!("Failed to request to tsserver {}", err); + LspError::invalid_request() + })?; + let mut refactor_actions = Vec::::new(); + for refactor_info in refactor_infos.iter() { + refactor_actions + .extend(refactor_info.to_code_actions(&specifier, ¶ms.range)); + } + all_actions.extend( + refactor::prune_invalid_actions(&refactor_actions, 5) + .into_iter() + .map(CodeActionOrCommand::CodeAction), + ); + + let response = if !all_actions.is_empty() { + Some(all_actions) + } else { + None + }; self.performance.measure(mark); - Ok(Some(code_action_response)) + Ok(response) } async fn code_action_resolve( &mut self, params: CodeAction, ) -> LspResult { + if params.kind.is_none() || params.data.is_none() { + return Ok(params); + } + let mark = self.performance.mark("code_action_resolve", Some(¶ms)); - let result = if let Some(data) = params.data.clone() { + let kind = params.kind.clone().unwrap(); + let data = params.data.clone().unwrap(); + + let result = if kind.as_str().starts_with(CodeActionKind::QUICKFIX.as_str()) + { let code_action_data: CodeActionData = from_value(data).map_err(|err| { error!("Unable to decode code action data: {}", err); @@ -1289,35 +1340,69 @@ impl Inner { })?; if combined_code_actions.commands.is_some() { error!("Deno does not support code actions with commands."); - Err(LspError::invalid_request()) - } else { - let changes = if code_action_data.fix_id == "fixMissingImport" { - fix_ts_import_changes( - &code_action_data.specifier, - &combined_code_actions.changes, - self, - ) - .map_err(|err| { - error!("Unable to remap changes: {}", err); - LspError::internal_error() - })? - } else { - combined_code_actions.changes.clone() - }; - let mut code_action = params.clone(); - code_action.edit = - ts_changes_to_edit(&changes, self).await.map_err(|err| { - error!("Unable to convert changes to edits: {}", err); - LspError::internal_error() - })?; - Ok(code_action) + return Err(LspError::invalid_request()); } + + let changes = if code_action_data.fix_id == "fixMissingImport" { + fix_ts_import_changes( + &code_action_data.specifier, + &combined_code_actions.changes, + self, + ) + .map_err(|err| { + error!("Unable to remap changes: {}", err); + LspError::internal_error() + })? + } else { + combined_code_actions.changes.clone() + }; + let mut code_action = params.clone(); + code_action.edit = + ts_changes_to_edit(&changes, self).await.map_err(|err| { + error!("Unable to convert changes to edits: {}", err); + LspError::internal_error() + })?; + code_action + } else if kind.as_str().starts_with(CodeActionKind::REFACTOR.as_str()) { + let mut code_action = params.clone(); + let action_data: refactor::RefactorCodeActionData = from_value(data) + .map_err(|err| { + error!("Unable to decode code action data: {}", err); + LspError::invalid_params("The CodeAction's data is invalid.") + })?; + let line_index = + self.get_line_index_sync(&action_data.specifier).unwrap(); + let start = line_index.offset_tsc(action_data.range.start)?; + let length = line_index.offset_tsc(action_data.range.end)? - start; + let req = tsc::RequestMethod::GetEditsForRefactor(( + action_data.specifier.clone(), + tsc::TextSpan { start, length }, + action_data.refactor_name.clone(), + action_data.action_name.clone(), + )); + let refactor_edit_info: tsc::RefactorEditInfo = self + .ts_server + .request(self.snapshot()?, req) + .await + .map_err(|err| { + error!("Failed to request to tsserver {}", err); + LspError::invalid_request() + })?; + code_action.edit = refactor_edit_info + .to_workspace_edit(self) + .await + .map_err(|err| { + error!("Unable to convert changes to edits: {}", err); + LspError::internal_error() + })?; + code_action } else { // The code action doesn't need to be resolved - Ok(params) + params }; + self.performance.measure(mark); - result + Ok(result) } async fn code_lens( diff --git a/cli/lsp/mod.rs b/cli/lsp/mod.rs index c05241ae12..0404d64e08 100644 --- a/cli/lsp/mod.rs +++ b/cli/lsp/mod.rs @@ -16,6 +16,7 @@ mod lsp_custom; mod parent_process_checker; mod path_to_regex; mod performance; +mod refactor; mod registries; mod semantic_tokens; mod sources; diff --git a/cli/lsp/refactor.rs b/cli/lsp/refactor.rs new file mode 100644 index 0000000000..17ab4d5de1 --- /dev/null +++ b/cli/lsp/refactor.rs @@ -0,0 +1,131 @@ +// Copyright 2018-2021 the Deno authors. All rights reserved. MIT license. + +// The logic of this module is heavily influenced by +// https://github.com/microsoft/vscode/blob/main/extensions/typescript-language-features/src/languageFeatures/refactor.ts + +use deno_core::serde::Deserialize; +use deno_core::serde::Serialize; +use deno_core::ModuleSpecifier; +use lspower::lsp; + +pub struct RefactorCodeActionKind { + pub kind: lsp::CodeActionKind, + matches_callback: Box bool + Send + Sync>, +} + +impl RefactorCodeActionKind { + pub fn matches(&self, tag: &str) -> bool { + (self.matches_callback)(tag) + } +} + +lazy_static::lazy_static! { + pub static ref EXTRACT_FUNCTION: RefactorCodeActionKind = RefactorCodeActionKind { + kind : [lsp::CodeActionKind::REFACTOR_EXTRACT.as_str(), "function"].join(".").into(), + matches_callback: Box::new(|tag: &str| tag.starts_with("function_")), + }; + + pub static ref EXTRACT_CONSTANT: RefactorCodeActionKind = RefactorCodeActionKind { + kind : [lsp::CodeActionKind::REFACTOR_EXTRACT.as_str(), "constant"].join(".").into(), + matches_callback: Box::new(|tag: &str| tag.starts_with("constant_")), + }; + + pub static ref EXTRACT_TYPE: RefactorCodeActionKind = RefactorCodeActionKind { + kind : [lsp::CodeActionKind::REFACTOR_EXTRACT.as_str(), "type"].join(".").into(), + matches_callback: Box::new(|tag: &str| tag.starts_with("Extract to type alias")), + }; + + pub static ref EXTRACT_INTERFACE: RefactorCodeActionKind = RefactorCodeActionKind { + kind : [lsp::CodeActionKind::REFACTOR_EXTRACT.as_str(), "interface"].join(".").into(), + matches_callback: Box::new(|tag: &str| tag.starts_with("Extract to interface")), + }; + + pub static ref MOVE_NEWFILE: RefactorCodeActionKind = RefactorCodeActionKind { + kind : [lsp::CodeActionKind::REFACTOR.as_str(), "move", "newFile"].join(".").into(), + matches_callback: Box::new(|tag: &str| tag.starts_with("Move to a new file")), + }; + + pub static ref REWRITE_IMPORT: RefactorCodeActionKind = RefactorCodeActionKind { + kind : [lsp::CodeActionKind::REFACTOR_REWRITE.as_str(), "import"].join(".").into(), + matches_callback: Box::new(|tag: &str| tag.starts_with("Convert namespace import") || tag.starts_with("Convert named imports")), + }; + + pub static ref REWRITE_EXPORT: RefactorCodeActionKind = RefactorCodeActionKind { + kind : [lsp::CodeActionKind::REFACTOR_REWRITE.as_str(), "export"].join(".").into(), + matches_callback: Box::new(|tag: &str| tag.starts_with("Convert default export") || tag.starts_with("Convert named export")), + }; + + pub static ref REWRITE_ARROW_BRACES: RefactorCodeActionKind = RefactorCodeActionKind { + kind : [lsp::CodeActionKind::REFACTOR_REWRITE.as_str(), "arrow", "braces"].join(".").into(), + matches_callback: Box::new(|tag: &str| tag.starts_with("Add or remove braces in an arrow function")), + }; + + pub static ref REWRITE_PARAMETERS_TO_DESTRUCTURED: RefactorCodeActionKind = RefactorCodeActionKind { + kind : [lsp::CodeActionKind::REFACTOR_REWRITE.as_str(), "parameters", "toDestructured"].join(".").into(), + matches_callback: Box::new(|tag: &str| tag.starts_with("Convert parameters to destructured object")), + }; + + pub static ref REWRITE_PROPERTY_GENERATEACCESSORS: RefactorCodeActionKind = RefactorCodeActionKind { + kind : [lsp::CodeActionKind::REFACTOR_REWRITE.as_str(), "property", "generateAccessors"].join(".").into(), + matches_callback: Box::new(|tag: &str| tag.starts_with("Generate 'get' and 'set' accessors")), + }; + + pub static ref ALL_KNOWN_REFACTOR_ACTION_KINDS: Vec<&'static RefactorCodeActionKind> = vec![ + &EXTRACT_FUNCTION, + &EXTRACT_CONSTANT, + &EXTRACT_TYPE, + &EXTRACT_INTERFACE, + &MOVE_NEWFILE, + &REWRITE_IMPORT, + &REWRITE_EXPORT, + &REWRITE_ARROW_BRACES, + &REWRITE_PARAMETERS_TO_DESTRUCTURED, + &REWRITE_PROPERTY_GENERATEACCESSORS + ]; +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RefactorCodeActionData { + pub specifier: ModuleSpecifier, + pub range: lsp::Range, + pub refactor_name: String, + pub action_name: String, +} + +pub fn prune_invalid_actions( + actions: &[lsp::CodeAction], + number_of_invalid: usize, +) -> Vec { + let mut available_actions = Vec::::new(); + let mut invalid_common_actions = Vec::::new(); + let mut invalid_uncommon_actions = Vec::::new(); + for action in actions { + if action.disabled.is_none() { + available_actions.push(action.clone()); + continue; + } + + // These are the common refactors that we should always show if applicable. + let action_kind = + action.kind.as_ref().map(|a| a.as_str()).unwrap_or_default(); + if action_kind.starts_with(EXTRACT_CONSTANT.kind.as_str()) + || action_kind.starts_with(EXTRACT_FUNCTION.kind.as_str()) + { + invalid_common_actions.push(action.clone()); + continue; + } + + // These are the remaining refactors that we can show if we haven't reached the max limit with just common refactors. + invalid_uncommon_actions.push(action.clone()); + } + + let mut prioritized_actions = Vec::::new(); + prioritized_actions.extend(invalid_common_actions); + prioritized_actions.extend(invalid_uncommon_actions); + let top_n_invalid = prioritized_actions + [0..std::cmp::min(number_of_invalid, prioritized_actions.len())] + .to_vec(); + available_actions.extend(top_n_invalid); + available_actions +} diff --git a/cli/lsp/tsc.rs b/cli/lsp/tsc.rs index c3f82fea8f..c5c3c08a9b 100644 --- a/cli/lsp/tsc.rs +++ b/cli/lsp/tsc.rs @@ -6,6 +6,11 @@ use super::code_lens; use super::config; use super::language_server; use super::language_server::StateSnapshot; +use super::refactor::RefactorCodeActionData; +use super::refactor::ALL_KNOWN_REFACTOR_ACTION_KINDS; +use super::refactor::EXTRACT_CONSTANT; +use super::refactor::EXTRACT_INTERFACE; +use super::refactor::EXTRACT_TYPE; use super::semantic_tokens::SemanticTokensBuilder; use super::semantic_tokens::TsTokenEncodingConsts; use super::text; @@ -1004,6 +1009,47 @@ impl FileTextChanges { edits, }) } + + pub(crate) async fn to_text_document_change_ops( + &self, + language_server: &mut language_server::Inner, + ) -> Result, AnyError> { + let mut ops = Vec::::new(); + let specifier = normalize_specifier(&self.file_name)?; + let line_index = if !self.is_new_file.unwrap_or(false) { + language_server.get_line_index(specifier.clone()).await? + } else { + LineIndex::new("") + }; + + if self.is_new_file.unwrap_or(false) { + ops.push(lsp::DocumentChangeOperation::Op(lsp::ResourceOp::Create( + lsp::CreateFile { + uri: specifier.clone(), + options: Some(lsp::CreateFileOptions { + ignore_if_exists: Some(true), + overwrite: None, + }), + annotation_id: None, + }, + ))); + } + + let edits = self + .text_changes + .iter() + .map(|tc| tc.as_text_edit(&line_index)) + .collect(); + ops.push(lsp::DocumentChangeOperation::Edit(lsp::TextDocumentEdit { + text_document: lsp::OptionalVersionedTextDocumentIdentifier { + uri: specifier.clone(), + version: language_server.document_version(specifier), + }, + edits, + })); + + Ok(ops) + } } #[derive(Debug, Deserialize)] @@ -1056,6 +1102,149 @@ impl Classifications { } } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RefactorActionInfo { + name: String, + description: String, + #[serde(skip_serializing_if = "Option::is_none")] + not_applicable_reason: Option, + #[serde(skip_serializing_if = "Option::is_none")] + kind: Option, +} + +impl RefactorActionInfo { + pub fn get_action_kind(&self) -> lsp::CodeActionKind { + if let Some(kind) = &self.kind { + kind.clone().into() + } else { + let maybe_match = ALL_KNOWN_REFACTOR_ACTION_KINDS + .iter() + .find(|action| action.matches(&self.name)); + maybe_match + .map_or(lsp::CodeActionKind::REFACTOR, |action| action.kind.clone()) + } + } + + pub fn is_preferred(&self, all_actions: &[RefactorActionInfo]) -> bool { + if EXTRACT_CONSTANT.matches(&self.name) { + let get_scope = |name: &str| -> Option { + let scope_regex = Regex::new(r"scope_(\d)").unwrap(); + if let Some(captures) = scope_regex.captures(name) { + captures[1].parse::().ok() + } else { + None + } + }; + + return if let Some(scope) = get_scope(&self.name) { + all_actions + .iter() + .filter(|other| { + !std::ptr::eq(&self, other) && EXTRACT_CONSTANT.matches(&other.name) + }) + .all(|other| { + if let Some(other_scope) = get_scope(&other.name) { + scope < other_scope + } else { + true + } + }) + } else { + false + }; + } + if EXTRACT_TYPE.matches(&self.name) || EXTRACT_INTERFACE.matches(&self.name) + { + return true; + } + false + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ApplicableRefactorInfo { + name: String, + description: String, + #[serde(skip_serializing_if = "Option::is_none")] + inlineable: Option, + actions: Vec, +} + +impl ApplicableRefactorInfo { + pub fn to_code_actions( + &self, + specifier: &ModuleSpecifier, + range: &lsp::Range, + ) -> Vec { + let mut code_actions = Vec::::new(); + // All typescript refactoring actions are inlineable + for action in self.actions.iter() { + code_actions + .push(self.as_inline_code_action(action, specifier, range, &self.name)); + } + code_actions + } + + fn as_inline_code_action( + &self, + action: &RefactorActionInfo, + specifier: &ModuleSpecifier, + range: &lsp::Range, + refactor_name: &str, + ) -> lsp::CodeAction { + let disabled = action.not_applicable_reason.as_ref().map(|reason| { + lsp::CodeActionDisabled { + reason: reason.clone(), + } + }); + + lsp::CodeAction { + title: action.description.to_string(), + kind: Some(action.get_action_kind()), + is_preferred: Some(action.is_preferred(&self.actions)), + disabled, + data: Some( + serde_json::to_value(RefactorCodeActionData { + specifier: specifier.clone(), + range: *range, + refactor_name: refactor_name.to_owned(), + action_name: action.name.clone(), + }) + .unwrap(), + ), + ..Default::default() + } + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RefactorEditInfo { + edits: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub rename_location: Option, +} + +impl RefactorEditInfo { + pub(crate) async fn to_workspace_edit( + &self, + language_server: &mut language_server::Inner, + ) -> Result, AnyError> { + let mut all_ops = Vec::::new(); + for edit in self.edits.iter() { + let ops = edit.to_text_document_change_ops(language_server).await?; + all_ops.extend(ops); + } + + Ok(Some(lsp::WorkspaceEdit { + document_changes: Some(lsp::DocumentChanges::Operations(all_ops)), + ..Default::default() + })) + } +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CodeAction { @@ -2421,6 +2610,10 @@ pub enum RequestMethod { }, /// Retrieve the text of an assets that exists in memory in the isolate. GetAsset(ModuleSpecifier), + /// Retrieve the possible refactor info for a range of a file. + GetApplicableRefactors((ModuleSpecifier, TextSpan, String)), + /// Retrieve the refactor edit info for a range. + GetEditsForRefactor((ModuleSpecifier, TextSpan, String, String)), /// Retrieve code fixes for a range of a file with the provided error codes. GetCodeFixes((ModuleSpecifier, u32, u32, Vec)), /// Get completion information at a given position (IntelliSense). @@ -2491,6 +2684,26 @@ impl RequestMethod { "method": "getAsset", "specifier": specifier, }), + RequestMethod::GetApplicableRefactors((specifier, span, kind)) => json!({ + "id": id, + "method": "getApplicableRefactors", + "specifier": state.denormalize_specifier(specifier), + "range": { "pos": span.start, "end": span.start + span.length}, + "kind": kind, + }), + RequestMethod::GetEditsForRefactor(( + specifier, + span, + refactor_name, + action_name, + )) => json!({ + "id": id, + "method": "getEditsForRefactor", + "specifier": state.denormalize_specifier(specifier), + "range": { "pos": span.start, "end": span.start + span.length}, + "refactorName": refactor_name, + "actionName": action_name, + }), RequestMethod::GetCodeFixes(( specifier, start_pos, diff --git a/cli/tests/integration/lsp_tests.rs b/cli/tests/integration/lsp_tests.rs index 8bf019aa73..e66b59c2b7 100644 --- a/cli/tests/integration/lsp_tests.rs +++ b/cli/tests/integration/lsp_tests.rs @@ -2115,6 +2115,45 @@ fn lsp_code_actions_imports() { shutdown(&mut client); } +#[test] +fn lsp_code_actions_refactor() { + let mut client = init("initialize_params.json"); + did_open( + &mut client, + json!({ + "textDocument": { + "uri": "file:///a/file.ts", + "languageId": "typescript", + "version": 1, + "text": "var x: { a?: number; b?: string } = {};\n" + } + }), + ); + let (maybe_res, maybe_err) = client + .write_request( + "textDocument/codeAction", + load_fixture("code_action_params_refactor.json"), + ) + .unwrap(); + assert!(maybe_err.is_none()); + assert_eq!( + maybe_res, + Some(load_fixture("code_action_response_refactor.json")) + ); + let (maybe_res, maybe_err) = client + .write_request( + "codeAction/resolve", + load_fixture("code_action_resolve_params_refactor.json"), + ) + .unwrap(); + assert!(maybe_err.is_none()); + assert_eq!( + maybe_res, + Some(load_fixture("code_action_resolve_response_refactor.json")) + ); + shutdown(&mut client); +} + #[test] fn lsp_code_actions_deadlock() { let mut client = init("initialize_params.json"); diff --git a/cli/tests/lsp/code_action_params_refactor.json b/cli/tests/lsp/code_action_params_refactor.json new file mode 100644 index 0000000000..9fe359498e --- /dev/null +++ b/cli/tests/lsp/code_action_params_refactor.json @@ -0,0 +1,21 @@ +{ + "textDocument": { + "uri": "file:///a/file.ts" + }, + "range": { + "start": { + "line": 0, + "character": 7 + }, + "end": { + "line": 0, + "character": 33 + } + }, + "context": { + "diagnostics": [], + "only": [ + "refactor" + ] + } +} diff --git a/cli/tests/lsp/code_action_resolve_params_refactor.json b/cli/tests/lsp/code_action_resolve_params_refactor.json new file mode 100644 index 0000000000..d4bb3bd81c --- /dev/null +++ b/cli/tests/lsp/code_action_resolve_params_refactor.json @@ -0,0 +1,20 @@ +{ + "title": "Extract to interface", + "kind": "refactor.extract.interface", + "isPreferred": true, + "data": { + "specifier": "file:///a/file.ts", + "range": { + "start": { + "line": 0, + "character": 7 + }, + "end": { + "line": 0, + "character": 33 + } + }, + "refactorName": "Extract type", + "actionName": "Extract to interface" + } +} diff --git a/cli/tests/lsp/code_action_resolve_response_refactor.json b/cli/tests/lsp/code_action_resolve_response_refactor.json new file mode 100644 index 0000000000..721a76a6b8 --- /dev/null +++ b/cli/tests/lsp/code_action_resolve_response_refactor.json @@ -0,0 +1,58 @@ +{ + "title": "Extract to interface", + "kind": "refactor.extract.interface", + "edit": { + "documentChanges": [ + { + "textDocument": { + "uri": "file:///a/file.ts", + "version": 1 + }, + "edits": [ + { + "range": { + "start": { + "line": 0, + "character": 0 + }, + "end": { + "line": 0, + "character": 0 + } + }, + "newText": "interface NewType {\n a?: number;\n b?: string;\n}\n\n" + }, + { + "range": { + "start": { + "line": 0, + "character": 7 + }, + "end": { + "line": 0, + "character": 33 + } + }, + "newText": "NewType" + } + ] + } + ] + }, + "isPreferred": true, + "data": { + "specifier": "file:///a/file.ts", + "range": { + "start": { + "line": 0, + "character": 7 + }, + "end": { + "line": 0, + "character": 33 + } + }, + "refactorName": "Extract type", + "actionName": "Extract to interface" + } +} diff --git a/cli/tests/lsp/code_action_response_refactor.json b/cli/tests/lsp/code_action_response_refactor.json new file mode 100644 index 0000000000..87f354e37c --- /dev/null +++ b/cli/tests/lsp/code_action_response_refactor.json @@ -0,0 +1,157 @@ +[ + { + "title": "Extract to type alias", + "kind": "refactor.extract.type", + "isPreferred": true, + "data": { + "specifier": "file:///a/file.ts", + "range": { + "start": { + "line": 0, + "character": 7 + }, + "end": { + "line": 0, + "character": 33 + } + }, + "refactorName": "Extract type", + "actionName": "Extract to type alias" + } + }, + { + "title": "Extract to interface", + "kind": "refactor.extract.interface", + "isPreferred": true, + "data": { + "specifier": "file:///a/file.ts", + "range": { + "start": { + "line": 0, + "character": 7 + }, + "end": { + "line": 0, + "character": 33 + } + }, + "refactorName": "Extract type", + "actionName": "Extract to interface" + } + }, + { + "title": "Extract function", + "kind": "refactor.extract.function", + "isPreferred": false, + "disabled": { + "reason": "Statement or expression expected." + }, + "data": { + "specifier": "file:///a/file.ts", + "range": { + "start": { + "line": 0, + "character": 7 + }, + "end": { + "line": 0, + "character": 33 + } + }, + "refactorName": "Extract Symbol", + "actionName": "Extract Function" + } + }, + { + "title": "Extract constant", + "kind": "refactor.extract.constant", + "isPreferred": false, + "disabled": { + "reason": "Statement or expression expected." + }, + "data": { + "specifier": "file:///a/file.ts", + "range": { + "start": { + "line": 0, + "character": 7 + }, + "end": { + "line": 0, + "character": 33 + } + }, + "refactorName": "Extract Symbol", + "actionName": "Extract Constant" + } + }, + { + "title": "Convert default export to named export", + "kind": "refactor.rewrite.export.named", + "isPreferred": false, + "disabled": { + "reason": "Could not find export statement" + }, + "data": { + "specifier": "file:///a/file.ts", + "range": { + "start": { + "line": 0, + "character": 7 + }, + "end": { + "line": 0, + "character": 33 + } + }, + "refactorName": "Convert export", + "actionName": "Convert default export to named export" + } + }, + { + "title": "Convert named export to default export", + "kind": "refactor.rewrite.export.default", + "isPreferred": false, + "disabled": { + "reason": "Could not find export statement" + }, + "data": { + "specifier": "file:///a/file.ts", + "range": { + "start": { + "line": 0, + "character": 7 + }, + "end": { + "line": 0, + "character": 33 + } + }, + "refactorName": "Convert export", + "actionName": "Convert named export to default export" + } + }, + { + "title": "Convert namespace import to named imports", + "kind": "refactor.rewrite.import.named", + "isPreferred": false, + "disabled": { + "reason": "Selection is not an import declaration." + }, + "data": { + "specifier": "file:///a/file.ts", + "range": { + "start": { + "line": 0, + "character": 7 + }, + "end": { + "line": 0, + "character": 33 + } + }, + "refactorName": "Convert import", + "actionName": "Convert namespace import to named imports" + } + } +] diff --git a/cli/tsc/99_main_compiler.js b/cli/tsc/99_main_compiler.js index f5cfe38dd7..29a3878874 100644 --- a/cli/tsc/99_main_compiler.js +++ b/cli/tsc/99_main_compiler.js @@ -584,6 +584,44 @@ delete Object.prototype.__proto__; ); return respond(id, sourceFile && sourceFile.text); } + case "getApplicableRefactors": { + return respond( + id, + languageService.getApplicableRefactors( + request.specifier, + request.range, + { + quotePreference: "double", + allowTextChangesInNewFiles: true, + provideRefactorNotApplicableReason: true, + }, + undefined, + request.kind, + ), + ); + } + case "getEditsForRefactor": { + return respond( + id, + languageService.getEditsForRefactor( + request.specifier, + { + indentSize: 2, + indentStyle: ts.IndentStyle.Smart, + semicolons: ts.SemicolonPreference.Insert, + convertTabsToSpaces: true, + insertSpaceBeforeAndAfterBinaryOperators: true, + insertSpaceAfterCommaDelimiter: true, + }, + request.range, + request.refactorName, + request.actionName, + { + quotePreference: "double", + }, + ), + ); + } case "getCodeFixes": { return respond( id, diff --git a/cli/tsc/compiler.d.ts b/cli/tsc/compiler.d.ts index 949d98ee0b..ff2e59e8e2 100644 --- a/cli/tsc/compiler.d.ts +++ b/cli/tsc/compiler.d.ts @@ -47,6 +47,8 @@ declare global { | ConfigureRequest | FindRenameLocationsRequest | GetAsset + | GetApplicableRefactors + | GetEditsForRefactor | GetCodeFixes | GetCombinedCodeFix | GetCompletionDetails @@ -92,6 +94,21 @@ declare global { specifier: string; } + interface GetApplicableRefactors extends BaseLanguageServerRequest { + method: "getApplicableRefactors"; + specifier: string; + range: ts.TextRange; + kind: string; + } + + interface GetEditsForRefactor extends BaseLanguageServerRequest { + method: "getEditsForRefactor"; + specifier: string; + range: ts.TextRange; + refactorName: string; + actionName: string; + } + interface GetCodeFixes extends BaseLanguageServerRequest { method: "getCodeFixes"; specifier: string;