diff --git a/cli/lsp/README.md b/cli/lsp/README.md index 87a662fc30..c43590f60f 100644 --- a/cli/lsp/README.md +++ b/cli/lsp/README.md @@ -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. diff --git a/cli/lsp/analysis.rs b/cli/lsp/analysis.rs index 95c0e95ff7..1584ca79d4 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) -> 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, + b: &Option, +) -> 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( + changes: &[tsc::FileTextChanges], + index_provider: &F, + version_provider: &V, +) -> Result, AnyError> +where + F: Fn(ModuleSpecifier) -> Fut + Clone, + Fut: Future>, + V: Fn(ModuleSpecifier) -> Option, +{ + 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, +} + +impl CodeActionCollection { + /// Add a TypeScript code fix action to the code actions collection. + pub async fn add_ts_fix_action( + &mut self, + action: &tsc::CodeFixAction, + diagnostic: &lsp::Diagnostic, + index_provider: &F, + version_provider: &V, + ) -> Result<(), AnyError> + where + F: Fn(ModuleSpecifier) -> Fut + Clone, + Fut: Future>, + V: Fn(ModuleSpecifier) -> Option, + { + 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( + &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>, + V: Fn(ModuleSpecifier) -> Option, + { + 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::*; diff --git a/cli/lsp/capabilities.rs b/cli/lsp/capabilities.rs index 6e8082ee8a..93afbce868 100644 --- a/cli/lsp/capabilities.rs +++ b/cli/lsp/capabilities.rs @@ -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; @@ -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 { @@ -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), }), diff --git a/cli/lsp/language_server.rs b/cli/lsp/language_server.rs index 52022632c2..d7d034db02 100644 --- a/cli/lsp/language_server.rs +++ b/cli/lsp/language_server.rs @@ -16,6 +16,7 @@ use lspower::lsp::request::*; use lspower::lsp::*; use lspower::Client; use regex::Regex; +use serde_json::from_value; use std::cell::RefCell; use std::collections::HashMap; use std::env; @@ -29,6 +30,7 @@ use crate::import_map::ImportMap; use crate::tsc_config::parse_config; use crate::tsc_config::TsConfig; +use super::analysis::CodeActionCollection; use super::analysis::CodeLensData; use super::analysis::CodeLensSource; use super::capabilities; @@ -63,16 +65,31 @@ pub struct StateSnapshot { #[derive(Debug)] struct Inner { + /// Cached versions of "fixed" assets that can either be inlined in Rust or + /// are part of the TypeScript snapshot and have to be fetched out. assets: HashMap>, + /// The LSP client that this LSP server is connected to. client: Client, + /// Configuration information. config: Config, + /// A collection of diagnostics from different sources. diagnostics: DiagnosticCollection, + /// The "in-memory" documents in the editor which can be updated and changed. documents: DocumentCache, + /// An optional URL which provides the location of a TypeScript configuration + /// file which will be used by the Deno LSP. maybe_config_uri: Option, + /// An optional import map which is used to resolve modules. maybe_import_map: Option, + /// The URL for the import map which is used to determine relative imports. maybe_import_map_uri: Option, + /// A collection of measurements which instrument that performance of the LSP. performance: Performance, + /// Cached sources that are read-only. sources: Sources, + /// A memoized version of fixable diagnostic codes retrieved from TypeScript. + ts_fixable_diagnostics: Vec, + /// An abstraction that handles interactions with TypeScript. ts_server: TsServer, } @@ -101,6 +118,7 @@ impl Inner { maybe_import_map_uri: Default::default(), performance: Default::default(), sources, + ts_fixable_diagnostics: Default::default(), ts_server: TsServer::new(), } } @@ -177,7 +195,9 @@ impl Inner { specifier: &ModuleSpecifier, ) -> Result { if self.documents.contains(specifier) { + let mark = self.performance.mark("get_navigation_tree"); if let Some(navigation_tree) = self.documents.navigation_tree(specifier) { + self.performance.measure(mark); Ok(navigation_tree) } else { let res = self @@ -193,6 +213,7 @@ impl Inner { self .documents .set_navigation_tree(specifier, navigation_tree.clone())?; + self.performance.measure(mark); Ok(navigation_tree) } } else { @@ -485,6 +506,7 @@ impl Inner { params: InitializeParams, ) -> LspResult { info!("Starting Deno language server..."); + let mark = self.performance.mark("initialize"); let capabilities = capabilities::server_capabilities(¶ms.capabilities); @@ -522,6 +544,24 @@ impl Inner { warn!("Updating tsconfig has errored: {}", err); } + if capabilities.code_action_provider.is_some() { + let res = self + .ts_server + .request(self.snapshot(), tsc::RequestMethod::GetSupportedCodeFixes) + .await + .map_err(|err| { + error!("Unable to get fixable diagnostics: {}", err); + LspError::internal_error() + })?; + let fixable_diagnostics: Vec = + from_value(res).map_err(|err| { + error!("Unable to get fixable diagnostics: {}", err); + LspError::internal_error() + })?; + self.ts_fixable_diagnostics = fixable_diagnostics; + } + + self.performance.measure(mark); Ok(InitializeResult { capabilities, server_info: Some(server_info), @@ -818,6 +858,129 @@ impl Inner { } } + async fn code_action( + &mut self, + params: CodeActionParams, + ) -> LspResult> { + if !self.enabled() { + return Ok(None); + } + + let mark = self.performance.mark("code_action"); + let specifier = utils::normalize_url(params.text_document.uri); + let fixable_diagnostics: Vec<&Diagnostic> = params + .context + .diagnostics + .iter() + .filter(|d| match &d.source { + Some(source) => match source.as_str() { + "deno-ts" => match &d.code { + Some(NumberOrString::String(code)) => { + self.ts_fixable_diagnostics.contains(code) + } + Some(NumberOrString::Number(code)) => { + self.ts_fixable_diagnostics.contains(&code.to_string()) + } + _ => false, + }, + // currently only processing `deno-ts` quick fixes + _ => false, + }, + 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 file_diagnostics: Vec<&Diagnostic> = self + .diagnostics + .diagnostics_for(&specifier, &DiagnosticSource::TypeScript) + .collect(); + let mut code_actions = CodeActionCollection::default(); + for diagnostic in &fixable_diagnostics { + let code = match &diagnostic.code.clone().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 res = + self + .ts_server + .request(self.snapshot(), req) + .await + .map_err(|err| { + error!("Error getting actions from TypeScript: {}", err); + LspError::internal_error() + })?; + let actions: Vec = + from_value(res).map_err(|err| { + error!("Cannot decode actions from TypeScript: {}", err); + LspError::internal_error() + })?; + for action in actions { + code_actions + .add_ts_fix_action( + &action, + diagnostic, + &|s| self.get_line_index(s), + &|s| self.documents.version(&s), + ) + .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 req = tsc::RequestMethod::GetCombinedCodeFix(( + specifier.clone(), + json!(action.fix_id.clone().unwrap()), + )); + let res = + self.ts_server.request(self.snapshot(), req).await.map_err( + |err| { + error!("Unable to get combined fix from TypeScript: {}", err); + LspError::internal_error() + }, + )?; + let combined_code_actions: tsc::CombinedCodeActions = from_value(res) + .map_err(|err| { + error!("Cannot decode combined actions from TypeScript: {}", err); + LspError::internal_error() + })?; + code_actions + .add_ts_fix_all_action( + &action, + diagnostic, + &combined_code_actions, + &|s| self.get_line_index(s), + &|s| self.documents.version(&s), + ) + .await + .map_err(|err| { + error!("Unable to add fix all: {}", err); + LspError::internal_error() + })?; + } + } + } + code_actions.set_preferred_fixes(); + let code_action_response = code_actions.get_response(); + self.performance.measure(mark); + Ok(Some(code_action_response)) + } + async fn code_lens( &mut self, params: CodeLensParams, @@ -1438,6 +1601,13 @@ impl lspower::LanguageServer for LanguageServer { self.0.lock().await.hover(params).await } + async fn code_action( + &self, + params: CodeActionParams, + ) -> LspResult> { + self.0.lock().await.code_action(params).await + } + async fn code_lens( &self, params: CodeLensParams, @@ -1512,6 +1682,7 @@ struct VirtualTextDocumentParams { text_document: TextDocumentIdentifier, } +// These are implementations of custom commands supported by the LSP impl Inner { async fn cache(&mut self, params: CacheParams) -> LspResult { let mark = self.performance.mark("cache"); @@ -1623,6 +1794,7 @@ mod tests { RequestAny, Request(u64, Value), RequestAssert(V), + RequestFixture(u64, String), } type LspTestHarnessRequest = (&'static str, LspResponse); @@ -1667,6 +1839,20 @@ mod tests { Some(jsonrpc::Outgoing::Response(resp)) => assert(json!(resp)), _ => panic!("unexpected result: {:?}", result), }, + LspResponse::RequestFixture(id, res_path_str) => { + let res_path = fixtures_path.join(res_path_str); + let res_str = fs::read_to_string(res_path).unwrap(); + match result { + Some(jsonrpc::Outgoing::Response(resp)) => assert_eq!( + resp, + jsonrpc::Response::ok( + jsonrpc::Id::Number(*id), + serde_json::from_str(&res_str).unwrap() + ) + ), + _ => panic!("unexpected result: {:?}", result), + } + } }, Err(err) => panic!("Error result: {}", err), } @@ -2121,6 +2307,25 @@ mod tests { harness.run().await; } + #[tokio::test] + async fn test_code_actions() { + let mut harness = LspTestHarness::new(vec![ + ("initialize_request.json", LspResponse::RequestAny), + ("initialized_notification.json", LspResponse::None), + ("did_open_notification_code_action.json", LspResponse::None), + ( + "code_action_request.json", + LspResponse::RequestFixture(2, "code_action_response.json".to_string()), + ), + ( + "shutdown_request.json", + LspResponse::Request(3, json!(null)), + ), + ("exit_notification.json", LspResponse::None), + ]); + harness.run().await; + } + #[derive(Deserialize)] struct PerformanceAverages { averages: Vec, @@ -2166,7 +2371,7 @@ mod tests { LspResponse::RequestAssert(|value| { let resp: PerformanceResponse = serde_json::from_value(value).unwrap(); - assert_eq!(resp.result.averages.len(), 9); + assert_eq!(resp.result.averages.len(), 10); }), ), ( diff --git a/cli/lsp/tsc.rs b/cli/lsp/tsc.rs index 3fee900c61..579979b069 100644 --- a/cli/lsp/tsc.rs +++ b/cli/lsp/tsc.rs @@ -354,7 +354,7 @@ impl From for lsp::CompletionItemKind { } } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct TextSpan { pub start: u32, @@ -710,6 +710,90 @@ impl DocumentHighlights { } } +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct TextChange { + span: TextSpan, + new_text: String, +} + +impl TextChange { + pub fn as_text_edit( + &self, + line_index: &LineIndex, + ) -> lsp::OneOf { + lsp::OneOf::Left(lsp::TextEdit { + range: self.span.to_range(line_index), + new_text: self.new_text.clone(), + }) + } +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct FileTextChanges { + file_name: String, + text_changes: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + is_new_file: Option, +} + +impl FileTextChanges { + pub async fn to_text_document_edit( + &self, + index_provider: &F, + version_provider: &V, + ) -> Result + where + F: Fn(ModuleSpecifier) -> Fut + Clone, + Fut: Future>, + V: Fn(ModuleSpecifier) -> Option, + { + let specifier = ModuleSpecifier::resolve_url(&self.file_name)?; + let line_index = index_provider(specifier.clone()).await?; + let edits = self + .text_changes + .iter() + .map(|tc| tc.as_text_edit(&line_index)) + .collect(); + Ok(lsp::TextDocumentEdit { + text_document: lsp::OptionalVersionedTextDocumentIdentifier { + uri: specifier.as_url().clone(), + version: version_provider(specifier), + }, + edits, + }) + } +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CodeFixAction { + pub description: String, + pub changes: Vec, + // These are opaque types that should just be passed back when applying the + // action. + #[serde(skip_serializing_if = "Option::is_none")] + pub commands: Option>, + pub fix_name: String, + // It appears currently that all fixIds are strings, but the protocol + // specifies an opaque type, the problem is that we need to use the id as a + // hash key, and `Value` does not implement hash (and it could provide a false + // positive depending on JSON whitespace, so we deserialize it but it might + // break in the future) + #[serde(skip_serializing_if = "Option::is_none")] + pub fix_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub fix_all_description: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CombinedCodeActions { + pub changes: Vec, + pub commands: Option>, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ReferenceEntry { @@ -1215,8 +1299,12 @@ pub enum RequestMethod { FindRenameLocations((ModuleSpecifier, u32, bool, bool, bool)), /// Retrieve the text of an assets that exists in memory in the isolate. GetAsset(ModuleSpecifier), + /// 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). GetCompletions((ModuleSpecifier, u32, UserPreferences)), + /// Retrieve the combined code fixes for a fix id for a module. + GetCombinedCodeFix((ModuleSpecifier, Value)), /// Get declaration information for a specific position. GetDefinition((ModuleSpecifier, u32)), /// Return diagnostics for given file. @@ -1231,6 +1319,8 @@ pub enum RequestMethod { GetQuickInfo((ModuleSpecifier, u32)), /// Get document references for a specific position. GetReferences((ModuleSpecifier, u32)), + /// Get the diagnostic codes that support some form of code fix. + GetSupportedCodeFixes, } impl RequestMethod { @@ -1263,6 +1353,25 @@ impl RequestMethod { "method": "getAsset", "specifier": specifier, }), + RequestMethod::GetCodeFixes(( + specifier, + start_pos, + end_pos, + error_codes, + )) => json!({ + "id": id, + "method": "getCodeFixes", + "specifier": specifier, + "startPosition": start_pos, + "endPosition": end_pos, + "errorCodes": error_codes, + }), + RequestMethod::GetCombinedCodeFix((specifier, fix_id)) => json!({ + "id": id, + "method": "getCombinedCodeFix", + "specifier": specifier, + "fixId": fix_id, + }), RequestMethod::GetCompletions((specifier, position, preferences)) => { json!({ "id": id, @@ -1317,6 +1426,10 @@ impl RequestMethod { "specifier": specifier, "position": position, }), + RequestMethod::GetSupportedCodeFixes => json!({ + "id": id, + "method": "getSupportedCodeFixes", + }), } } } diff --git a/cli/tests/lsp/code_action_request.json b/cli/tests/lsp/code_action_request.json new file mode 100644 index 0000000000..af6cbee8bd --- /dev/null +++ b/cli/tests/lsp/code_action_request.json @@ -0,0 +1,44 @@ +{ + "jsonrpc": "2.0", + "id": 2, + "method": "textDocument/codeAction", + "params": { + "textDocument": { + "uri": "file:///a/file.ts" + }, + "range": { + "start": { + "line": 1, + "character": 2 + }, + "end": { + "line": 1, + "character": 7 + } + }, + "context": { + "diagnostics": [ + { + "range": { + "start": { + "line": 1, + "character": 2 + }, + "end": { + "line": 1, + "character": 7 + } + }, + "severity": 1, + "code": 1308, + "source": "deno-ts", + "message": "'await' expressions are only allowed within async functions and at the top levels of modules.", + "relatedInformation": [] + } + ], + "only": [ + "quickfix" + ] + } + } +} diff --git a/cli/tests/lsp/code_action_response.json b/cli/tests/lsp/code_action_response.json new file mode 100644 index 0000000000..5af45ba7f5 --- /dev/null +++ b/cli/tests/lsp/code_action_response.json @@ -0,0 +1,150 @@ +[ + { + "title": "Add async modifier to containing function", + "kind": "quickfix", + "diagnostics": [ + { + "range": { + "start": { + "line": 1, + "character": 2 + }, + "end": { + "line": 1, + "character": 7 + } + }, + "severity": 1, + "code": 1308, + "source": "deno-ts", + "message": "'await' expressions are only allowed within async functions and at the top levels of modules.", + "relatedInformation": [] + } + ], + "edit": { + "documentChanges": [ + { + "textDocument": { + "uri": "file:///a/file.ts", + "version": 1 + }, + "edits": [ + { + "range": { + "start": { + "line": 0, + "character": 7 + }, + "end": { + "line": 0, + "character": 7 + } + }, + "newText": "async " + }, + { + "range": { + "start": { + "line": 0, + "character": 21 + }, + "end": { + "line": 0, + "character": 25 + } + }, + "newText": "Promise" + } + ] + } + ] + } + }, + { + "title": "Add all missing 'async' modifiers", + "kind": "quickfix", + "diagnostics": [ + { + "range": { + "start": { + "line": 1, + "character": 2 + }, + "end": { + "line": 1, + "character": 7 + } + }, + "severity": 1, + "code": 1308, + "source": "deno-ts", + "message": "'await' expressions are only allowed within async functions and at the top levels of modules.", + "relatedInformation": [] + } + ], + "edit": { + "documentChanges": [ + { + "textDocument": { + "uri": "file:///a/file.ts", + "version": 1 + }, + "edits": [ + { + "range": { + "start": { + "line": 0, + "character": 7 + }, + "end": { + "line": 0, + "character": 7 + } + }, + "newText": "async " + }, + { + "range": { + "start": { + "line": 0, + "character": 21 + }, + "end": { + "line": 0, + "character": 25 + } + }, + "newText": "Promise" + }, + { + "range": { + "start": { + "line": 4, + "character": 7 + }, + "end": { + "line": 4, + "character": 7 + } + }, + "newText": "async " + }, + { + "range": { + "start": { + "line": 4, + "character": 21 + }, + "end": { + "line": 4, + "character": 25 + } + }, + "newText": "Promise" + } + ] + } + ] + } + } +] diff --git a/cli/tests/lsp/did_open_notification_code_action.json b/cli/tests/lsp/did_open_notification_code_action.json new file mode 100644 index 0000000000..57559cf3c4 --- /dev/null +++ b/cli/tests/lsp/did_open_notification_code_action.json @@ -0,0 +1,12 @@ +{ + "jsonrpc": "2.0", + "method": "textDocument/didOpen", + "params": { + "textDocument": { + "uri": "file:///a/file.ts", + "languageId": "typescript", + "version": 1, + "text": "export function a(): void {\n await Promise.resolve(\"a\");\n}\n\nexport function b(): void {\n await Promise.resolve(\"b\");\n}\n" + } + } +} diff --git a/cli/tests/lsp/initialize_request.json b/cli/tests/lsp/initialize_request.json index 46f96a2c56..eaea00a182 100644 --- a/cli/tests/lsp/initialize_request.json +++ b/cli/tests/lsp/initialize_request.json @@ -20,6 +20,22 @@ }, "capabilities": { "textDocument": { + "codeAction": { + "codeActionLiteralSupport": { + "codeActionKind": { + "valueSet": [ + "quickfix" + ] + } + }, + "isPreferredSupport": true, + "dataSupport": true, + "resolveSupport": { + "properties": [ + "edit" + ] + } + }, "synchronization": { "dynamicRegistration": true, "willSave": true, diff --git a/cli/tsc/99_main_compiler.js b/cli/tsc/99_main_compiler.js index fa25b207f1..50631e83f8 100644 --- a/cli/tsc/99_main_compiler.js +++ b/cli/tsc/99_main_compiler.js @@ -555,6 +555,45 @@ delete Object.prototype.__proto__; ); return respond(id, sourceFile && sourceFile.text); } + case "getCodeFixes": { + return respond( + id, + languageService.getCodeFixesAtPosition( + request.specifier, + request.startPosition, + request.endPosition, + request.errorCodes.map((v) => Number(v)), + { + indentSize: 2, + indentStyle: ts.IndentStyle.Block, + semicolons: ts.SemicolonPreference.Insert, + }, + { + quotePreference: "double", + }, + ), + ); + } + case "getCombinedCodeFix": { + return respond( + id, + languageService.getCombinedCodeFix( + { + type: "file", + fileName: request.specifier, + }, + request.fixId, + { + indentSize: 2, + indentStyle: ts.IndentStyle.Block, + semicolons: ts.SemicolonPreference.Insert, + }, + { + quotePreference: "double", + }, + ), + ); + } case "getCompletions": { return respond( id, @@ -638,6 +677,12 @@ delete Object.prototype.__proto__; ), ); } + case "getSupportedCodeFixes": { + return respond( + id, + ts.getSupportedCodeFixes(), + ); + } default: throw new TypeError( // @ts-ignore exhausted case statement sets type to never diff --git a/cli/tsc/compiler.d.ts b/cli/tsc/compiler.d.ts index 17d6ddb38a..4e5dcdb964 100644 --- a/cli/tsc/compiler.d.ts +++ b/cli/tsc/compiler.d.ts @@ -44,6 +44,8 @@ declare global { | ConfigureRequest | FindRenameLocationsRequest | GetAsset + | GetCodeFixes + | GetCombinedCodeFix | GetCompletionsRequest | GetDefinitionRequest | GetDiagnosticsRequest @@ -51,7 +53,8 @@ declare global { | GetImplementationRequest | GetNavigationTree | GetQuickInfoRequest - | GetReferencesRequest; + | GetReferencesRequest + | GetSupportedCodeFixes; interface BaseLanguageServerRequest { id: number; @@ -78,6 +81,21 @@ declare global { specifier: string; } + interface GetCodeFixes extends BaseLanguageServerRequest { + method: "getCodeFixes"; + specifier: string; + startPosition: number; + endPosition: number; + errorCodes: string[]; + } + + interface GetCombinedCodeFix extends BaseLanguageServerRequest { + method: "getCombinedCodeFix"; + specifier: string; + // deno-lint-ignore ban-types + fixId: {}; + } + interface GetCompletionsRequest extends BaseLanguageServerRequest { method: "getCompletions"; specifier: string; @@ -125,4 +143,8 @@ declare global { specifier: string; position: number; } + + interface GetSupportedCodeFixes extends BaseLanguageServerRequest { + method: "getSupportedCodeFixes"; + } } diff --git a/op_crates/fetch/lib.rs b/op_crates/fetch/lib.rs index 157ce2fb22..456358b833 100644 --- a/op_crates/fetch/lib.rs +++ b/op_crates/fetch/lib.rs @@ -37,6 +37,7 @@ use std::cell::RefCell; use std::convert::From; use std::fs::File; use std::io::Read; +use std::path::Path; use std::path::PathBuf; use std::pin::Pin; use std::rc::Rc; @@ -82,7 +83,7 @@ pub fn init(isolate: &mut JsRuntime) { pub trait FetchPermissions { fn check_net_url(&self, _url: &Url) -> Result<(), AnyError>; - fn check_read(&self, _p: &PathBuf) -> Result<(), AnyError>; + fn check_read(&self, _p: &Path) -> Result<(), AnyError>; } /// For use with `op_fetch` when the user does not want permissions. @@ -93,7 +94,7 @@ impl FetchPermissions for NoFetchPermissions { Ok(()) } - fn check_read(&self, _p: &PathBuf) -> Result<(), AnyError> { + fn check_read(&self, _p: &Path) -> Result<(), AnyError> { Ok(()) } } diff --git a/runtime/permissions.rs b/runtime/permissions.rs index f9c74a253e..b568c0a4ff 100644 --- a/runtime/permissions.rs +++ b/runtime/permissions.rs @@ -624,7 +624,7 @@ impl deno_fetch::FetchPermissions for Permissions { Permissions::check_net_url(self, url) } - fn check_read(&self, p: &PathBuf) -> Result<(), AnyError> { + fn check_read(&self, p: &Path) -> Result<(), AnyError> { Permissions::check_read(self, p) } }