summaryrefslogtreecommitdiff
path: root/cli/lsp/analysis.rs
diff options
context:
space:
mode:
authorKitson Kelly <me@kitsonkelly.com>2021-02-05 05:53:02 +1100
committerGitHub <noreply@github.com>2021-02-05 05:53:02 +1100
commitb77fcbc518428429e39f5ba94e41fcd0418ee7a0 (patch)
tree0051cc7e5ac0c8f83278de093a2be7209cf9fcfc /cli/lsp/analysis.rs
parent644a7ff2d70cbd8bfba4c87b75a047e79830c4b6 (diff)
feat(lsp): add TS quick fix code actions (#9396)
Diffstat (limited to 'cli/lsp/analysis.rs')
-rw-r--r--cli/lsp/analysis.rs289
1 files changed, 289 insertions, 0 deletions
diff --git a/cli/lsp/analysis.rs b/cli/lsp/analysis.rs
index 95c0e95ff..1584ca79d 100644
--- a/cli/lsp/analysis.rs
+++ b/cli/lsp/analysis.rs
@@ -1,5 +1,8 @@
// Copyright 2018-2021 the Deno authors. All rights reserved. MIT license.
+use super::text::LineIndex;
+use super::tsc;
+
use crate::ast;
use crate::import_map::ImportMap;
use crate::media_type::MediaType;
@@ -8,7 +11,9 @@ use crate::module_graph::parse_ts_reference;
use crate::module_graph::TypeScriptReference;
use crate::tools::lint::create_linter;
+use deno_core::error::custom_error;
use deno_core::error::AnyError;
+use deno_core::futures::Future;
use deno_core::serde::Deserialize;
use deno_core::serde::Serialize;
use deno_core::ModuleSpecifier;
@@ -16,9 +21,40 @@ use deno_lint::rules;
use lspower::lsp;
use lspower::lsp::Position;
use lspower::lsp::Range;
+use std::cmp::Ordering;
use std::collections::HashMap;
use std::rc::Rc;
+lazy_static! {
+ /// Diagnostic error codes which actually are the same, and so when grouping
+ /// fixes we treat them the same.
+ static ref FIX_ALL_ERROR_CODES: HashMap<&'static str, &'static str> =
+ [("2339", "2339"), ("2345", "2339"),]
+ .iter()
+ .copied()
+ .collect();
+
+ /// Fixes which help determine if there is a preferred fix when there are
+ /// multiple fixes available.
+ static ref PREFERRED_FIXES: HashMap<&'static str, (u32, bool)> = [
+ ("annotateWithTypeFromJSDoc", (1, false)),
+ ("constructorForDerivedNeedSuperCall", (1, false)),
+ ("extendsInterfaceBecomesImplements", (1, false)),
+ ("awaitInSyncFunction", (1, false)),
+ ("classIncorrectlyImplementsInterface", (3, false)),
+ ("classDoesntImplementInheritedAbstractMember", (3, false)),
+ ("unreachableCode", (1, false)),
+ ("unusedIdentifier", (1, false)),
+ ("forgottenThisPropertyAccess", (1, false)),
+ ("spelling", (2, false)),
+ ("addMissingAwait", (1, false)),
+ ("fixImport", (0, true)),
+ ]
+ .iter()
+ .copied()
+ .collect();
+}
+
/// Category of self-generated diagnostic messages (those not coming from)
/// TypeScript.
pub enum Category {
@@ -264,6 +300,259 @@ pub struct CodeLensData {
pub specifier: ModuleSpecifier,
}
+fn code_as_string(code: &Option<lsp::NumberOrString>) -> String {
+ match code {
+ Some(lsp::NumberOrString::String(str)) => str.clone(),
+ Some(lsp::NumberOrString::Number(num)) => num.to_string(),
+ _ => "".to_string(),
+ }
+}
+
+/// Determines if two TypeScript diagnostic codes are effectively equivalent.
+fn is_equivalent_code(
+ a: &Option<lsp::NumberOrString>,
+ b: &Option<lsp::NumberOrString>,
+) -> bool {
+ let a_code = code_as_string(a);
+ let b_code = code_as_string(b);
+ FIX_ALL_ERROR_CODES.get(a_code.as_str())
+ == FIX_ALL_ERROR_CODES.get(b_code.as_str())
+}
+
+/// Return a boolean flag to indicate if the specified action is the preferred
+/// action for a given set of actions.
+fn is_preferred(
+ action: &tsc::CodeFixAction,
+ actions: &[(lsp::CodeAction, tsc::CodeFixAction)],
+ fix_priority: u32,
+ only_one: bool,
+) -> bool {
+ actions.iter().all(|(_, a)| {
+ if action == a {
+ return true;
+ }
+ if a.fix_id.is_some() {
+ return true;
+ }
+ if let Some((other_fix_priority, _)) =
+ PREFERRED_FIXES.get(a.fix_name.as_str())
+ {
+ match other_fix_priority.cmp(&fix_priority) {
+ Ordering::Less => return true,
+ Ordering::Greater => return false,
+ Ordering::Equal => (),
+ }
+ if only_one && action.fix_name == a.fix_name {
+ return false;
+ }
+ }
+ true
+ })
+}
+
+/// Convert changes returned from a TypeScript quick fix action into edits
+/// for an LSP CodeAction.
+async fn ts_changes_to_edit<F, Fut, V>(
+ changes: &[tsc::FileTextChanges],
+ index_provider: &F,
+ version_provider: &V,
+) -> Result<Option<lsp::WorkspaceEdit>, AnyError>
+where
+ F: Fn(ModuleSpecifier) -> Fut + Clone,
+ Fut: Future<Output = Result<LineIndex, AnyError>>,
+ V: Fn(ModuleSpecifier) -> Option<i32>,
+{
+ let mut text_document_edits = Vec::new();
+ for change in changes {
+ let text_document_edit = change
+ .to_text_document_edit(index_provider, version_provider)
+ .await?;
+ text_document_edits.push(text_document_edit);
+ }
+ Ok(Some(lsp::WorkspaceEdit {
+ changes: None,
+ document_changes: Some(lsp::DocumentChanges::Edits(text_document_edits)),
+ change_annotations: None,
+ }))
+}
+
+#[derive(Debug, Default)]
+pub struct CodeActionCollection {
+ actions: Vec<(lsp::CodeAction, tsc::CodeFixAction)>,
+ fix_all_actions: HashMap<String, (lsp::CodeAction, tsc::CodeFixAction)>,
+}
+
+impl CodeActionCollection {
+ /// Add a TypeScript code fix action to the code actions collection.
+ pub async fn add_ts_fix_action<F, Fut, V>(
+ &mut self,
+ action: &tsc::CodeFixAction,
+ diagnostic: &lsp::Diagnostic,
+ index_provider: &F,
+ version_provider: &V,
+ ) -> Result<(), AnyError>
+ where
+ F: Fn(ModuleSpecifier) -> Fut + Clone,
+ Fut: Future<Output = Result<LineIndex, AnyError>>,
+ V: Fn(ModuleSpecifier) -> Option<i32>,
+ {
+ if action.commands.is_some() {
+ // In theory, tsc can return actions that require "commands" to be applied
+ // back into TypeScript. Currently there is only one command, `install
+ // package` but Deno doesn't support that. The problem is that the
+ // `.applyCodeActionCommand()` returns a promise, and with the current way
+ // we wrap tsc, we can't handle the asynchronous response, so it is
+ // actually easier to return errors if we ever encounter one of these,
+ // which we really wouldn't expect from the Deno lsp.
+ return Err(custom_error(
+ "UnsupportedFix",
+ "The action returned from TypeScript is unsupported.",
+ ));
+ }
+ let edit =
+ ts_changes_to_edit(&action.changes, index_provider, version_provider)
+ .await?;
+ let code_action = lsp::CodeAction {
+ title: action.description.clone(),
+ kind: Some(lsp::CodeActionKind::QUICKFIX),
+ diagnostics: Some(vec![diagnostic.clone()]),
+ edit,
+ command: None,
+ is_preferred: None,
+ disabled: None,
+ data: None,
+ };
+ self.actions.retain(|(c, a)| {
+ !(action.fix_name == a.fix_name && code_action.edit == c.edit)
+ });
+ self.actions.push((code_action, action.clone()));
+
+ if let Some(fix_id) = &action.fix_id {
+ if let Some((existing_fix_all, existing_action)) =
+ self.fix_all_actions.get(fix_id)
+ {
+ self.actions.retain(|(c, _)| c != existing_fix_all);
+ self
+ .actions
+ .push((existing_fix_all.clone(), existing_action.clone()));
+ }
+ }
+ Ok(())
+ }
+
+ /// Add a TypeScript action to the actions as a "fix all" action, where it
+ /// will fix all occurrences of the diagnostic in the file.
+ pub async fn add_ts_fix_all_action<F, Fut, V>(
+ &mut self,
+ action: &tsc::CodeFixAction,
+ diagnostic: &lsp::Diagnostic,
+ combined_code_actions: &tsc::CombinedCodeActions,
+ index_provider: &F,
+ version_provider: &V,
+ ) -> Result<(), AnyError>
+ where
+ F: Fn(ModuleSpecifier) -> Fut + Clone,
+ Fut: Future<Output = Result<LineIndex, AnyError>>,
+ V: Fn(ModuleSpecifier) -> Option<i32>,
+ {
+ if combined_code_actions.commands.is_some() {
+ return Err(custom_error(
+ "UnsupportedFix",
+ "The action returned from TypeScript is unsupported.",
+ ));
+ }
+ let edit = ts_changes_to_edit(
+ &combined_code_actions.changes,
+ index_provider,
+ version_provider,
+ )
+ .await?;
+ let title = if let Some(description) = &action.fix_all_description {
+ description.clone()
+ } else {
+ format!("{} (Fix all in file)", action.description)
+ };
+
+ let code_action = lsp::CodeAction {
+ title,
+ kind: Some(lsp::CodeActionKind::QUICKFIX),
+ diagnostics: Some(vec![diagnostic.clone()]),
+ edit,
+ command: None,
+ is_preferred: None,
+ disabled: None,
+ data: None,
+ };
+ if let Some((existing, _)) =
+ self.fix_all_actions.get(&action.fix_id.clone().unwrap())
+ {
+ self.actions.retain(|(c, _)| c != existing);
+ }
+ self.actions.push((code_action.clone(), action.clone()));
+ self.fix_all_actions.insert(
+ action.fix_id.clone().unwrap(),
+ (code_action, action.clone()),
+ );
+ Ok(())
+ }
+
+ /// Move out the code actions and return them as a `CodeActionResponse`.
+ pub fn get_response(self) -> lsp::CodeActionResponse {
+ self
+ .actions
+ .into_iter()
+ .map(|(c, _)| lsp::CodeActionOrCommand::CodeAction(c))
+ .collect()
+ }
+
+ /// Determine if a action can be converted into a "fix all" action.
+ pub fn is_fix_all_action(
+ &self,
+ action: &tsc::CodeFixAction,
+ diagnostic: &lsp::Diagnostic,
+ file_diagnostics: &[&lsp::Diagnostic],
+ ) -> bool {
+ // If the action does not have a fix id (indicating it can be "bundled up")
+ // or if the collection already contains a "bundled" action return false
+ if action.fix_id.is_none()
+ || self
+ .fix_all_actions
+ .contains_key(&action.fix_id.clone().unwrap())
+ {
+ false
+ } else {
+ // else iterate over the diagnostic in the file and see if there are any
+ // other diagnostics that could be bundled together in a "fix all" code
+ // action
+ file_diagnostics.iter().any(|d| {
+ if d == &diagnostic || d.code.is_none() || diagnostic.code.is_none() {
+ false
+ } else {
+ d.code == diagnostic.code
+ || is_equivalent_code(&d.code, &diagnostic.code)
+ }
+ })
+ }
+ }
+
+ /// Set the `.is_preferred` flag on code actions, this should be only executed
+ /// when all actions are added to the collection.
+ pub fn set_preferred_fixes(&mut self) {
+ let actions = self.actions.clone();
+ for (code_action, action) in self.actions.iter_mut() {
+ if action.fix_id.is_some() {
+ continue;
+ }
+ if let Some((fix_priority, only_one)) =
+ PREFERRED_FIXES.get(action.fix_name.as_str())
+ {
+ code_action.is_preferred =
+ Some(is_preferred(action, &actions, *fix_priority, *only_one));
+ }
+ }
+ }
+}
+
#[cfg(test)]
mod tests {
use super::*;