0
0
Fork 0
mirror of https://github.com/denoland/deno.git synced 2025-03-03 09:31:22 -05:00

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
This commit is contained in:
David Sherret 2023-07-03 14:09:24 -04:00 committed by GitHub
parent 2c2e6adae8
commit e8a866ca8a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 449 additions and 19 deletions

View file

@ -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

View file

@ -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,
)

View file

@ -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)
})

View file

@ -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()
@ -5073,6 +5073,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();