diff options
author | David Sherret <dsherret@users.noreply.github.com> | 2023-07-03 14:09:24 -0400 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-07-03 14:09:24 -0400 |
commit | e8a866ca8a682b552722926161a7816c5cf94124 (patch) | |
tree | 8859ff48bc83c74bc333f51004ad2b03a54c60bf | |
parent | 2c2e6adae86874ccb9a3fd2843cf2b50a0847bac (diff) |
feat(lsp): support import maps in quick fix and auto-imports (#19692)
Closes https://github.com/denoland/vscode_deno/issues/849
Closes #15330
Closes #10951
Closes #13623
-rw-r--r-- | cli/lsp/analysis.rs | 81 | ||||
-rw-r--r-- | cli/lsp/language_server.rs | 1 | ||||
-rw-r--r-- | cli/lsp/tsc.rs | 4 | ||||
-rw-r--r-- | cli/tests/integration/lsp_tests.rs | 382 |
4 files changed, 449 insertions, 19 deletions
diff --git a/cli/lsp/analysis.rs b/cli/lsp/analysis.rs index 80c748055..ce1d1c296 100644 --- a/cli/lsp/analysis.rs +++ b/cli/lsp/analysis.rs @@ -22,6 +22,8 @@ use deno_core::ModuleSpecifier; use deno_lint::rules::LintRule; use deno_runtime::deno_node::PackageJson; use deno_runtime::deno_node::PathClean; +use deno_semver::npm::NpmPackageReq; +use import_map::ImportMap; use once_cell::sync::Lazy; use regex::Regex; use std::cmp::Ordering; @@ -157,6 +159,7 @@ fn code_as_string(code: &Option<lsp::NumberOrString>) -> String { /// Rewrites imports in quick fixes and code changes to be Deno specific. pub struct TsResponseImportMapper<'a> { documents: &'a Documents, + maybe_import_map: Option<&'a ImportMap>, npm_resolution: &'a NpmResolution, npm_resolver: &'a CliNpmResolver, } @@ -164,37 +167,81 @@ pub struct TsResponseImportMapper<'a> { impl<'a> TsResponseImportMapper<'a> { pub fn new( documents: &'a Documents, + maybe_import_map: Option<&'a ImportMap>, npm_resolution: &'a NpmResolution, npm_resolver: &'a CliNpmResolver, ) -> Self { Self { documents, + maybe_import_map, npm_resolution, npm_resolver, } } - pub fn check_specifier(&self, specifier: &ModuleSpecifier) -> Option<String> { + pub fn check_specifier( + &self, + specifier: &ModuleSpecifier, + referrer: &ModuleSpecifier, + ) -> Option<String> { + fn concat_npm_specifier( + prefix: &str, + pkg_req: &NpmPackageReq, + sub_path: Option<&str>, + ) -> String { + let result = format!("{}{}", prefix, pkg_req); + match sub_path { + Some(path) => format!("{}/{}", result, path), + None => result, + } + } + if self.npm_resolver.in_npm_package(specifier) { if let Ok(pkg_id) = self .npm_resolver .resolve_package_id_from_specifier(specifier) { - // todo(dsherret): once supporting an import map, we should prioritize which - // pkg requirement we use, based on what's specified in the import map - if let Some(pkg_req) = self - .npm_resolution - .resolve_pkg_reqs_from_pkg_id(&pkg_id) - .first() - { - let result = format!("npm:{}", pkg_req); - return Some(match self.resolve_package_path(specifier) { - Some(path) => format!("{}/{}", result, path), - None => result, - }); + let pkg_reqs = + self.npm_resolution.resolve_pkg_reqs_from_pkg_id(&pkg_id); + // check if any pkg reqs match what is found in an import map + if !pkg_reqs.is_empty() { + let sub_path = self.resolve_package_path(specifier); + if let Some(import_map) = self.maybe_import_map { + for pkg_req in &pkg_reqs { + let paths = vec![ + concat_npm_specifier("npm:", pkg_req, sub_path.as_deref()), + concat_npm_specifier("npm:/", pkg_req, sub_path.as_deref()), + ]; + for path in paths { + if let Some(mapped_path) = ModuleSpecifier::parse(&path) + .ok() + .and_then(|s| import_map.lookup(&s, referrer)) + { + return Some(mapped_path); + } + } + } + } + + // if not found in the import map, return the first pkg req + if let Some(pkg_req) = pkg_reqs.first() { + return Some(concat_npm_specifier( + "npm:", + pkg_req, + sub_path.as_deref(), + )); + } } } } + + // check if the import map has this specifier + if let Some(import_map) = self.maybe_import_map { + if let Some(result) = import_map.lookup(specifier, referrer) { + return Some(result); + } + } + None } @@ -238,13 +285,13 @@ impl<'a> TsResponseImportMapper<'a> { /// Iterate over the supported extensions, concatenating the extension on the /// specifier, returning the first specifier that is resolve-able, otherwise /// None if none match. - pub fn check_specifier_with_referrer( + pub fn check_unresolved_specifier( &self, specifier: &str, referrer: &ModuleSpecifier, ) -> Option<String> { if let Ok(specifier) = referrer.join(specifier) { - if let Some(specifier) = self.check_specifier(&specifier) { + if let Some(specifier) = self.check_specifier(&specifier, referrer) { return Some(specifier); } } @@ -338,7 +385,7 @@ pub fn fix_ts_import_changes( if let Some(captures) = IMPORT_SPECIFIER_RE.captures(line) { let specifier = captures.get(1).unwrap().as_str(); if let Some(new_specifier) = - import_mapper.check_specifier_with_referrer(specifier, referrer) + import_mapper.check_unresolved_specifier(specifier, referrer) { line.replace(specifier, &new_specifier) } else { @@ -387,7 +434,7 @@ fn fix_ts_import_action( .ok_or_else(|| anyhow!("Missing capture."))? .as_str(); if let Some(new_specifier) = - import_mapper.check_specifier_with_referrer(specifier, referrer) + import_mapper.check_unresolved_specifier(specifier, referrer) { let description = action.description.replace(specifier, &new_specifier); let changes = action diff --git a/cli/lsp/language_server.rs b/cli/lsp/language_server.rs index 0b6051212..61bd64eef 100644 --- a/cli/lsp/language_server.rs +++ b/cli/lsp/language_server.rs @@ -2085,6 +2085,7 @@ impl Inner { pub fn get_ts_response_import_mapper(&self) -> TsResponseImportMapper { TsResponseImportMapper::new( &self.documents, + self.maybe_import_map.as_deref(), &self.npm.resolution, &self.npm.resolver, ) diff --git a/cli/lsp/tsc.rs b/cli/lsp/tsc.rs index 00a5d2bc7..40c823d5b 100644 --- a/cli/lsp/tsc.rs +++ b/cli/lsp/tsc.rs @@ -2532,7 +2532,9 @@ fn update_import_statement( if let Ok(import_specifier) = normalize_specifier(&import_data.file_name) { if let Some(new_module_specifier) = maybe_import_mapper - .and_then(|m| m.check_specifier(&import_specifier)) + .and_then(|m| { + m.check_specifier(&import_specifier, &item_data.specifier) + }) .or_else(|| { relative_specifier(&item_data.specifier, &import_specifier) }) diff --git a/cli/tests/integration/lsp_tests.rs b/cli/tests/integration/lsp_tests.rs index 198a3d022..d6a8b4757 100644 --- a/cli/tests/integration/lsp_tests.rs +++ b/cli/tests/integration/lsp_tests.rs @@ -4824,7 +4824,7 @@ fn lsp_completions_auto_import() { } #[test] -fn lsp_npm_completions_auto_import_and_quick_fix() { +fn lsp_npm_completions_auto_import_and_quick_fix_no_import_map() { let context = TestContextBuilder::new() .use_http_server() .use_temp_cwd() @@ -5074,6 +5074,386 @@ fn lsp_npm_completions_auto_import_and_quick_fix() { } #[test] +fn lsp_completions_auto_import_and_quick_fix_with_import_map() { + let context = TestContextBuilder::new() + .use_http_server() + .use_temp_cwd() + .build(); + let temp_dir = context.temp_dir(); + let import_map = r#"{ + "imports": { + "print_hello": "http://localhost:4545/subdir/print_hello.ts", + "chalk": "npm:chalk@~5", + "types-exports-subpaths/": "npm:/@denotest/types-exports-subpaths@1/" + } + }"#; + temp_dir.write("import_map.json", import_map); + + let mut client = context.new_lsp_command().build(); + client.initialize(|builder| { + builder.set_import_map("import_map.json"); + }); + client.did_open( + json!({ + "textDocument": { + "uri": "file:///a/file.ts", + "languageId": "typescript", + "version": 1, + "text": concat!( + "import {getClient} from 'npm:@denotest/types-exports-subpaths@1/client';\n", + "import _test1 from 'npm:chalk@^5.0';\n", + "import chalk from 'npm:chalk@~5';\n", + "import chalk from 'npm:chalk@~5';\n", + "import {printHello} from 'print_hello';\n", + "\n", + ), + } + }), + ); + client.write_request( + "deno/cache", + json!({ + "referrer": { + "uri": "file:///a/file.ts", + }, + "uris": [ + { + "uri": "npm:@denotest/types-exports-subpaths@1/client", + }, { + "uri": "npm:chalk@^5.0", + }, { + "uri": "npm:chalk@~5", + }, { + "uri": "http://localhost:4545/subdir/print_hello.ts", + } + ] + }), + ); + + // try auto-import with path + client.did_open(json!({ + "textDocument": { + "uri": "file:///a/a.ts", + "languageId": "typescript", + "version": 1, + "text": "getClie", + } + })); + let list = client.get_completion_list( + "file:///a/a.ts", + (0, 7), + json!({ "triggerKind": 1 }), + ); + assert!(!list.is_incomplete); + let item = list + .items + .iter() + .find(|item| item.label == "getClient") + .unwrap(); + + let res = client.write_request("completionItem/resolve", item); + assert_eq!( + res, + json!({ + "label": "getClient", + "kind": 3, + "detail": "function getClient(): 5", + "documentation": { + "kind": "markdown", + "value": "" + }, + "sortText": "16", + "additionalTextEdits": [ + { + "range": { + "start": { "line": 0, "character": 0 }, + "end": { "line": 0, "character": 0 } + }, + "newText": "import { getClient } from \"types-exports-subpaths/client\";\n\n" + } + ] + }) + ); + + // try quick fix with path + let diagnostics = client.did_open(json!({ + "textDocument": { + "uri": "file:///a/b.ts", + "languageId": "typescript", + "version": 1, + "text": "getClient", + } + })); + let diagnostics = diagnostics + .messages_with_file_and_source("file:///a/b.ts", "deno-ts") + .diagnostics; + let res = client.write_request( + "textDocument/codeAction", + json!(json!({ + "textDocument": { + "uri": "file:///a/b.ts" + }, + "range": { + "start": { "line": 0, "character": 0 }, + "end": { "line": 0, "character": 9 } + }, + "context": { + "diagnostics": diagnostics, + "only": ["quickfix"] + } + })), + ); + assert_eq!( + res, + json!([{ + "title": "Add import from \"types-exports-subpaths/client\"", + "kind": "quickfix", + "diagnostics": [ + { + "range": { + "start": { "line": 0, "character": 0 }, + "end": { "line": 0, "character": 9 } + }, + "severity": 1, + "code": 2304, + "source": "deno-ts", + "message": "Cannot find name 'getClient'.", + } + ], + "edit": { + "documentChanges": [{ + "textDocument": { + "uri": "file:///a/b.ts", + "version": 1, + }, + "edits": [{ + "range": { + "start": { "line": 0, "character": 0 }, + "end": { "line": 0, "character": 0 } + }, + "newText": "import { getClient } from \"types-exports-subpaths/client\";\n\n" + }] + }] + } + }]) + ); + + // try auto-import without path + client.did_open(json!({ + "textDocument": { + "uri": "file:///a/c.ts", + "languageId": "typescript", + "version": 1, + "text": "chal", + } + })); + + let list = client.get_completion_list( + "file:///a/c.ts", + (0, 4), + json!({ "triggerKind": 1 }), + ); + assert!(!list.is_incomplete); + let item = list + .items + .iter() + .find(|item| item.label == "chalk") + .unwrap(); + + let mut res = client.write_request("completionItem/resolve", item); + let obj = res.as_object_mut().unwrap(); + obj.remove("detail"); // not worth testing these + obj.remove("documentation"); + assert_eq!( + res, + json!({ + "label": "chalk", + "kind": 6, + "sortText": "16", + "additionalTextEdits": [ + { + "range": { + "start": { "line": 0, "character": 0 }, + "end": { "line": 0, "character": 0 } + }, + "newText": "import chalk from \"chalk\";\n\n" + } + ] + }) + ); + + // try quick fix without path + let diagnostics = client.did_open(json!({ + "textDocument": { + "uri": "file:///a/d.ts", + "languageId": "typescript", + "version": 1, + "text": "chalk", + } + })); + let diagnostics = diagnostics + .messages_with_file_and_source("file:///a/d.ts", "deno-ts") + .diagnostics; + let res = client.write_request( + "textDocument/codeAction", + json!(json!({ + "textDocument": { + "uri": "file:///a/d.ts" + }, + "range": { + "start": { "line": 0, "character": 0 }, + "end": { "line": 0, "character": 5 } + }, + "context": { + "diagnostics": diagnostics, + "only": ["quickfix"] + } + })), + ); + assert_eq!( + res, + json!([{ + "title": "Add import from \"chalk\"", + "kind": "quickfix", + "diagnostics": [ + { + "range": { + "start": { "line": 0, "character": 0 }, + "end": { "line": 0, "character": 5 } + }, + "severity": 1, + "code": 2304, + "source": "deno-ts", + "message": "Cannot find name 'chalk'.", + } + ], + "edit": { + "documentChanges": [{ + "textDocument": { + "uri": "file:///a/d.ts", + "version": 1, + }, + "edits": [{ + "range": { + "start": { "line": 0, "character": 0 }, + "end": { "line": 0, "character": 0 } + }, + "newText": "import chalk from \"chalk\";\n\n" + }] + }] + } + }]) + ); + + // try auto-import with http import map + client.did_open(json!({ + "textDocument": { + "uri": "file:///a/e.ts", + "languageId": "typescript", + "version": 1, + "text": "printH", + } + })); + + let list = client.get_completion_list( + "file:///a/e.ts", + (0, 6), + json!({ "triggerKind": 1 }), + ); + assert!(!list.is_incomplete); + let item = list + .items + .iter() + .find(|item| item.label == "printHello") + .unwrap(); + + let mut res = client.write_request("completionItem/resolve", item); + let obj = res.as_object_mut().unwrap(); + obj.remove("detail"); // not worth testing these + obj.remove("documentation"); + assert_eq!( + res, + json!({ + "label": "printHello", + "kind": 3, + "sortText": "16", + "additionalTextEdits": [ + { + "range": { + "start": { "line": 0, "character": 0 }, + "end": { "line": 0, "character": 0 } + }, + "newText": "import { printHello } from \"print_hello\";\n\n" + } + ] + }) + ); + + // try quick fix with http import + let diagnostics = client.did_open(json!({ + "textDocument": { + "uri": "file:///a/f.ts", + "languageId": "typescript", + "version": 1, + "text": "printHello", + } + })); + let diagnostics = diagnostics + .messages_with_file_and_source("file:///a/f.ts", "deno-ts") + .diagnostics; + let res = client.write_request( + "textDocument/codeAction", + json!(json!({ + "textDocument": { + "uri": "file:///a/f.ts" + }, + "range": { + "start": { "line": 0, "character": 0 }, + "end": { "line": 0, "character": 10 } + }, + "context": { + "diagnostics": diagnostics, + "only": ["quickfix"] + } + })), + ); + assert_eq!( + res, + json!([{ + "title": "Add import from \"print_hello\"", + "kind": "quickfix", + "diagnostics": [ + { + "range": { + "start": { "line": 0, "character": 0 }, + "end": { "line": 0, "character": 10 } + }, + "severity": 1, + "code": 2304, + "source": "deno-ts", + "message": "Cannot find name 'printHello'.", + } + ], + "edit": { + "documentChanges": [{ + "textDocument": { + "uri": "file:///a/f.ts", + "version": 1, + }, + "edits": [{ + "range": { + "start": { "line": 0, "character": 0 }, + "end": { "line": 0, "character": 0 } + }, + "newText": "import { printHello } from \"print_hello\";\n\n" + }] + }] + } + }]) + ); +} + +#[test] fn lsp_completions_snippet() { let context = TestContextBuilder::new().use_temp_cwd().build(); let mut client = context.new_lsp_command().build(); |