Skip to content

Commit

Permalink
feat(lsp): add TS quick fix code actions (denoland#9396)
Browse files Browse the repository at this point in the history
  • Loading branch information
kitsonk committed Feb 4, 2021
1 parent 644a7ff commit b77fcbc
Show file tree
Hide file tree
Showing 13 changed files with 944 additions and 8 deletions.
18 changes: 18 additions & 0 deletions cli/lsp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,21 @@ integrated into the command line and can be started via the `lsp` sub-command.
When the language server is started, a `LanguageServer` instance is created
which holds all of the state of the language server. It also defines all of the
methods that the client calls via the Language Server RPC protocol.

## Custom requests

The LSP currently supports the following custom requests. A client should
implement these in order to have a fully functioning client that integrates well
with Deno:

- `deno/cache` - This command will instruct Deno to attempt to cache a module
and all of its dependencies. It expects an argument of
`{ textDocument: TextDocumentIdentifier }` to be passed.
- `deno/performance` - Requests the return of the timing averages for the
internal instrumentation of Deno.
- `deno/virtualTextDocument` - Requests a virtual text document from the LSP,
which is a read only document that can be displayed in the client. This allows
clients to access documents in the Deno cache, like remote modules and
TypeScript library files built into Deno. It also supports a special URL of
`deno:/status.md` which provides a markdown formatted text document that
contains details about the status of the LSP for display to a user.
289 changes: 289 additions & 0 deletions cli/lsp/analysis.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -8,17 +11,50 @@ 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;
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 {
Expand Down Expand Up @@ -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::*;
Expand Down
25 changes: 23 additions & 2 deletions cli/lsp/capabilities.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
///! client.
///!
use lspower::lsp::ClientCapabilities;
use lspower::lsp::CodeActionKind;
use lspower::lsp::CodeActionOptions;
use lspower::lsp::CodeActionProviderCapability;
use lspower::lsp::CodeLensOptions;
use lspower::lsp::CompletionOptions;
use lspower::lsp::HoverProviderCapability;
Expand All @@ -18,9 +21,27 @@ use lspower::lsp::TextDocumentSyncKind;
use lspower::lsp::TextDocumentSyncOptions;
use lspower::lsp::WorkDoneProgressOptions;

fn code_action_capabilities(
client_capabilities: &ClientCapabilities,
) -> CodeActionProviderCapability {
client_capabilities
.text_document
.as_ref()
.and_then(|it| it.code_action.as_ref())
.and_then(|it| it.code_action_literal_support.as_ref())
.map_or(CodeActionProviderCapability::Simple(true), |_| {
CodeActionProviderCapability::Options(CodeActionOptions {
code_action_kinds: Some(vec![CodeActionKind::QUICKFIX]),
resolve_provider: None,
work_done_progress_options: Default::default(),
})
})
}

pub fn server_capabilities(
_client_capabilities: &ClientCapabilities,
client_capabilities: &ClientCapabilities,
) -> ServerCapabilities {
let code_action_provider = code_action_capabilities(client_capabilities);
ServerCapabilities {
text_document_sync: Some(TextDocumentSyncCapability::Options(
TextDocumentSyncOptions {
Expand Down Expand Up @@ -59,7 +80,7 @@ pub fn server_capabilities(
document_highlight_provider: Some(OneOf::Left(true)),
document_symbol_provider: None,
workspace_symbol_provider: None,
code_action_provider: None,
code_action_provider: Some(code_action_provider),
code_lens_provider: Some(CodeLensOptions {
resolve_provider: Some(true),
}),
Expand Down
Loading

0 comments on commit b77fcbc

Please sign in to comment.