mirror of
https://github.com/denoland/deno.git
synced 2024-11-25 15:29:32 -05:00
feat(lsp): provide completions from import map if available (#13624)
Closes #13619
This commit is contained in:
parent
e218d567d5
commit
773f882e5e
7 changed files with 351 additions and 7 deletions
4
Cargo.lock
generated
4
Cargo.lock
generated
|
@ -1935,9 +1935,9 @@ checksum = "cb56e1aa765b4b4f3aadfab769793b7087bb03a4ea4920644a6d238e2df5b9ed"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "import_map"
|
name = "import_map"
|
||||||
version = "0.6.0"
|
version = "0.8.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f64f821df8ee00a0fba2dde6296af519eff7d823542b057c1b8c40ca1d58f4c"
|
checksum = "09ae88504e9128c4c181a0a4726d868d52aa76de270c7fb00c3c40a8f4fbace4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"indexmap",
|
"indexmap",
|
||||||
"log",
|
"log",
|
||||||
|
|
|
@ -68,7 +68,7 @@ encoding_rs = "=0.8.29"
|
||||||
env_logger = "=0.8.4"
|
env_logger = "=0.8.4"
|
||||||
fancy-regex = "=0.7.1"
|
fancy-regex = "=0.7.1"
|
||||||
http = "=0.2.4"
|
http = "=0.2.4"
|
||||||
import_map = "=0.6.0"
|
import_map = "=0.8.0"
|
||||||
jsonc-parser = { version = "=0.19.0", features = ["serde"] }
|
jsonc-parser = { version = "=0.19.0", features = ["serde"] }
|
||||||
libc = "=0.2.106"
|
libc = "=0.2.106"
|
||||||
log = { version = "=0.4.14", features = ["serde"] }
|
log = { version = "=0.4.14", features = ["serde"] }
|
||||||
|
|
|
@ -21,7 +21,14 @@ use deno_core::serde::Deserialize;
|
||||||
use deno_core::serde::Serialize;
|
use deno_core::serde::Serialize;
|
||||||
use deno_core::url::Position;
|
use deno_core::url::Position;
|
||||||
use deno_core::ModuleSpecifier;
|
use deno_core::ModuleSpecifier;
|
||||||
|
use import_map::ImportMap;
|
||||||
use lspower::lsp;
|
use lspower::lsp;
|
||||||
|
use once_cell::sync::Lazy;
|
||||||
|
use regex::Regex;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
static FILE_PROTO_RE: Lazy<Regex> =
|
||||||
|
Lazy::new(|| Regex::new(r#"^file:/{2}(?:/[A-Za-z]:)?"#).unwrap());
|
||||||
|
|
||||||
const CURRENT_PATH: &str = ".";
|
const CURRENT_PATH: &str = ".";
|
||||||
const PARENT_PATH: &str = "..";
|
const PARENT_PATH: &str = "..";
|
||||||
|
@ -126,12 +133,22 @@ pub(crate) async fn get_import_completions(
|
||||||
client: Client,
|
client: Client,
|
||||||
module_registries: &ModuleRegistry,
|
module_registries: &ModuleRegistry,
|
||||||
documents: &Documents,
|
documents: &Documents,
|
||||||
|
maybe_import_map: Option<Arc<ImportMap>>,
|
||||||
) -> Option<lsp::CompletionResponse> {
|
) -> Option<lsp::CompletionResponse> {
|
||||||
let document = documents.get(specifier)?;
|
let document = documents.get(specifier)?;
|
||||||
let (text, _, range) = document.get_maybe_dependency(position)?;
|
let (text, _, range) = document.get_maybe_dependency(position)?;
|
||||||
let range = to_narrow_lsp_range(&document.text_info(), &range);
|
let range = to_narrow_lsp_range(&document.text_info(), &range);
|
||||||
// completions for local relative modules
|
if let Some(completion_list) = get_import_map_completions(
|
||||||
if text.starts_with("./") || text.starts_with("../") {
|
specifier,
|
||||||
|
&text,
|
||||||
|
&range,
|
||||||
|
maybe_import_map.clone(),
|
||||||
|
documents,
|
||||||
|
) {
|
||||||
|
// completions for import map specifiers
|
||||||
|
Some(lsp::CompletionResponse::List(completion_list))
|
||||||
|
} else if text.starts_with("./") || text.starts_with("../") {
|
||||||
|
// completions for local relative modules
|
||||||
Some(lsp::CompletionResponse::List(lsp::CompletionList {
|
Some(lsp::CompletionResponse::List(lsp::CompletionList {
|
||||||
is_incomplete: false,
|
is_incomplete: false,
|
||||||
items: get_local_completions(specifier, &text, &range)?,
|
items: get_local_completions(specifier, &text, &range)?,
|
||||||
|
@ -155,6 +172,8 @@ pub(crate) async fn get_import_completions(
|
||||||
});
|
});
|
||||||
Some(lsp::CompletionResponse::List(list))
|
Some(lsp::CompletionResponse::List(list))
|
||||||
} else {
|
} else {
|
||||||
|
// the import specifier is empty, so provide all possible specifiers we are
|
||||||
|
// aware of
|
||||||
let mut items: Vec<lsp::CompletionItem> = LOCAL_PATHS
|
let mut items: Vec<lsp::CompletionItem> = LOCAL_PATHS
|
||||||
.iter()
|
.iter()
|
||||||
.map(|s| lsp::CompletionItem {
|
.map(|s| lsp::CompletionItem {
|
||||||
|
@ -167,6 +186,9 @@ pub(crate) async fn get_import_completions(
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let mut is_incomplete = false;
|
let mut is_incomplete = false;
|
||||||
|
if let Some(import_map) = maybe_import_map {
|
||||||
|
items.extend(get_base_import_map_completions(import_map.as_ref()));
|
||||||
|
}
|
||||||
if let Some(origin_items) =
|
if let Some(origin_items) =
|
||||||
module_registries.get_origin_completions(&text, &range)
|
module_registries.get_origin_completions(&text, &range)
|
||||||
{
|
{
|
||||||
|
@ -177,10 +199,133 @@ pub(crate) async fn get_import_completions(
|
||||||
is_incomplete,
|
is_incomplete,
|
||||||
items,
|
items,
|
||||||
}))
|
}))
|
||||||
// TODO(@kitsonk) add bare specifiers from import map
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// When the specifier is an empty string, return all the keys from the import
|
||||||
|
/// map as completion items.
|
||||||
|
fn get_base_import_map_completions(
|
||||||
|
import_map: &ImportMap,
|
||||||
|
) -> Vec<lsp::CompletionItem> {
|
||||||
|
import_map
|
||||||
|
.imports_keys()
|
||||||
|
.iter()
|
||||||
|
.map(|key| {
|
||||||
|
// for some strange reason, keys that start with `/` get stored in the
|
||||||
|
// import map as `file:///`, and so when we pull the keys out, we need to
|
||||||
|
// change the behavior
|
||||||
|
let mut label = if key.starts_with("file://") {
|
||||||
|
FILE_PROTO_RE.replace(key, "").to_string()
|
||||||
|
} else {
|
||||||
|
key.to_string()
|
||||||
|
};
|
||||||
|
let kind = if key.ends_with('/') {
|
||||||
|
label.pop();
|
||||||
|
Some(lsp::CompletionItemKind::FOLDER)
|
||||||
|
} else {
|
||||||
|
Some(lsp::CompletionItemKind::FILE)
|
||||||
|
};
|
||||||
|
lsp::CompletionItem {
|
||||||
|
label: label.clone(),
|
||||||
|
kind,
|
||||||
|
detail: Some("(import map)".to_string()),
|
||||||
|
sort_text: Some(label.clone()),
|
||||||
|
insert_text: Some(label),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Given an existing specifier, return any completions that could apply derived
|
||||||
|
/// from the import map. There are two main type of import map keys, those that
|
||||||
|
/// a literal, which don't end in `/`, which expects a one for one replacement
|
||||||
|
/// of specifier to specifier, and then those that end in `/` which indicates
|
||||||
|
/// that the path post the `/` should be appended to resolved specifier. This
|
||||||
|
/// handles both cases, pulling any completions from the workspace completions.
|
||||||
|
fn get_import_map_completions(
|
||||||
|
specifier: &ModuleSpecifier,
|
||||||
|
text: &str,
|
||||||
|
range: &lsp::Range,
|
||||||
|
maybe_import_map: Option<Arc<ImportMap>>,
|
||||||
|
documents: &Documents,
|
||||||
|
) -> Option<lsp::CompletionList> {
|
||||||
|
if !text.is_empty() {
|
||||||
|
if let Some(import_map) = maybe_import_map {
|
||||||
|
let mut items = Vec::new();
|
||||||
|
for key in import_map.imports_keys() {
|
||||||
|
// for some reason, the import_map stores keys that begin with `/` as
|
||||||
|
// `file:///` in its index, so we have to reverse that here
|
||||||
|
let key = if key.starts_with("file://") {
|
||||||
|
FILE_PROTO_RE.replace(key, "").to_string()
|
||||||
|
} else {
|
||||||
|
key.to_string()
|
||||||
|
};
|
||||||
|
if text.starts_with(&key) && key.ends_with('/') {
|
||||||
|
if let Ok(resolved) = import_map.resolve(&key, specifier) {
|
||||||
|
let resolved = resolved.to_string();
|
||||||
|
let workspace_items: Vec<lsp::CompletionItem> = documents
|
||||||
|
.documents(false, true)
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|d| {
|
||||||
|
let specifier_str = d.specifier().to_string();
|
||||||
|
let new_text = specifier_str.replace(&resolved, &key);
|
||||||
|
if specifier_str.starts_with(&resolved) {
|
||||||
|
let label = specifier_str.replace(&resolved, "");
|
||||||
|
let text_edit =
|
||||||
|
Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit {
|
||||||
|
range: *range,
|
||||||
|
new_text: new_text.clone(),
|
||||||
|
}));
|
||||||
|
Some(lsp::CompletionItem {
|
||||||
|
label,
|
||||||
|
kind: Some(lsp::CompletionItemKind::MODULE),
|
||||||
|
detail: Some("(import map)".to_string()),
|
||||||
|
sort_text: Some("1".to_string()),
|
||||||
|
filter_text: Some(new_text),
|
||||||
|
text_edit,
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
items.extend(workspace_items);
|
||||||
|
}
|
||||||
|
} else if key.starts_with(text) && text != key {
|
||||||
|
let mut label = key.to_string();
|
||||||
|
let kind = if key.ends_with('/') {
|
||||||
|
label.pop();
|
||||||
|
Some(lsp::CompletionItemKind::FOLDER)
|
||||||
|
} else {
|
||||||
|
Some(lsp::CompletionItemKind::MODULE)
|
||||||
|
};
|
||||||
|
let text_edit = Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit {
|
||||||
|
range: *range,
|
||||||
|
new_text: label.clone(),
|
||||||
|
}));
|
||||||
|
items.push(lsp::CompletionItem {
|
||||||
|
label: label.clone(),
|
||||||
|
kind,
|
||||||
|
detail: Some("(import map)".to_string()),
|
||||||
|
sort_text: Some("1".to_string()),
|
||||||
|
text_edit,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if !items.is_empty() {
|
||||||
|
return Some(lsp::CompletionList {
|
||||||
|
items,
|
||||||
|
is_incomplete: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
/// Return local completions that are relative to the base specifier.
|
/// Return local completions that are relative to the base specifier.
|
||||||
fn get_local_completions(
|
fn get_local_completions(
|
||||||
base: &ModuleSpecifier,
|
base: &ModuleSpecifier,
|
||||||
|
|
|
@ -1649,6 +1649,7 @@ impl Inner {
|
||||||
self.client.clone(),
|
self.client.clone(),
|
||||||
&self.module_registries,
|
&self.module_registries,
|
||||||
&self.documents,
|
&self.documents,
|
||||||
|
self.maybe_import_map.clone(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
|
|
|
@ -49,6 +49,7 @@ use deno_runtime::deno_tls::rustls::RootCertStore;
|
||||||
use deno_runtime::deno_web::BlobStore;
|
use deno_runtime::deno_web::BlobStore;
|
||||||
use deno_runtime::inspector_server::InspectorServer;
|
use deno_runtime::inspector_server::InspectorServer;
|
||||||
use deno_runtime::permissions::Permissions;
|
use deno_runtime::permissions::Permissions;
|
||||||
|
use import_map::parse_from_json;
|
||||||
use import_map::ImportMap;
|
use import_map::ImportMap;
|
||||||
use log::warn;
|
use log::warn;
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
@ -617,7 +618,7 @@ pub fn import_map_from_text(
|
||||||
specifier: &Url,
|
specifier: &Url,
|
||||||
json_text: &str,
|
json_text: &str,
|
||||||
) -> Result<ImportMap, AnyError> {
|
) -> Result<ImportMap, AnyError> {
|
||||||
let result = ImportMap::from_json_with_diagnostics(specifier, json_text)?;
|
let result = parse_from_json(specifier, json_text)?;
|
||||||
if !result.diagnostics.is_empty() {
|
if !result.diagnostics.is_empty() {
|
||||||
warn!(
|
warn!(
|
||||||
"Import map diagnostics:\n{}",
|
"Import map diagnostics:\n{}",
|
||||||
|
|
|
@ -547,6 +547,196 @@ fn lsp_import_assertions() {
|
||||||
shutdown(&mut client);
|
shutdown(&mut client);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lsp_import_map_import_completions() {
|
||||||
|
let temp_dir = TempDir::new().expect("could not create temp dir");
|
||||||
|
let mut params: lsp::InitializeParams =
|
||||||
|
serde_json::from_value(load_fixture("initialize_params.json")).unwrap();
|
||||||
|
let import_map =
|
||||||
|
serde_json::to_vec_pretty(&load_fixture("import-map-completions.json"))
|
||||||
|
.unwrap();
|
||||||
|
fs::write(temp_dir.path().join("import-map.json"), import_map).unwrap();
|
||||||
|
fs::create_dir(temp_dir.path().join("lib")).unwrap();
|
||||||
|
fs::write(
|
||||||
|
temp_dir.path().join("lib").join("b.ts"),
|
||||||
|
r#"export const b = "b";"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
params.root_uri = Some(Url::from_file_path(temp_dir.path()).unwrap());
|
||||||
|
if let Some(Value::Object(mut map)) = params.initialization_options {
|
||||||
|
map.insert("importMap".to_string(), json!("import-map.json"));
|
||||||
|
params.initialization_options = Some(Value::Object(map));
|
||||||
|
}
|
||||||
|
|
||||||
|
let deno_exe = deno_exe_path();
|
||||||
|
let mut client = LspClient::new(&deno_exe).unwrap();
|
||||||
|
client
|
||||||
|
.write_request::<_, _, Value>("initialize", params)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
client.write_notification("initialized", json!({})).unwrap();
|
||||||
|
let uri = Url::from_file_path(temp_dir.path().join("a.ts")).unwrap();
|
||||||
|
|
||||||
|
did_open(
|
||||||
|
&mut client,
|
||||||
|
json!({
|
||||||
|
"textDocument": {
|
||||||
|
"uri": uri,
|
||||||
|
"languageId": "typescript",
|
||||||
|
"version": 1,
|
||||||
|
"text": "import * as a from \"/~/b.ts\";\nimport * as b from \"\""
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
let (maybe_res, maybe_err) = client
|
||||||
|
.write_request(
|
||||||
|
"textDocument/completion",
|
||||||
|
json!({
|
||||||
|
"textDocument": {
|
||||||
|
"uri": uri
|
||||||
|
},
|
||||||
|
"position": {
|
||||||
|
"line": 1,
|
||||||
|
"character": 20
|
||||||
|
},
|
||||||
|
"context": {
|
||||||
|
"triggerKind": 2,
|
||||||
|
"triggerCharacter": "\""
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(maybe_err.is_none());
|
||||||
|
assert_eq!(
|
||||||
|
maybe_res,
|
||||||
|
Some(json!({
|
||||||
|
"isIncomplete": false,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"label": ".",
|
||||||
|
"kind": 19,
|
||||||
|
"detail": "(local)",
|
||||||
|
"sortText": "1",
|
||||||
|
"insertText": "."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "..",
|
||||||
|
"kind": 19,
|
||||||
|
"detail": "(local)",
|
||||||
|
"sortText": "1",
|
||||||
|
"insertText": ".."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "std",
|
||||||
|
"kind": 19,
|
||||||
|
"detail": "(import map)",
|
||||||
|
"sortText": "std",
|
||||||
|
"insertText": "std"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "fs",
|
||||||
|
"kind": 17,
|
||||||
|
"detail": "(import map)",
|
||||||
|
"sortText": "fs",
|
||||||
|
"insertText": "fs"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "/~",
|
||||||
|
"kind": 19,
|
||||||
|
"detail": "(import map)",
|
||||||
|
"sortText": "/~",
|
||||||
|
"insertText": "/~"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
client
|
||||||
|
.write_notification(
|
||||||
|
"textDocument/didChange",
|
||||||
|
json!({
|
||||||
|
"textDocument": {
|
||||||
|
"uri": uri,
|
||||||
|
"version": 2
|
||||||
|
},
|
||||||
|
"contentChanges": [
|
||||||
|
{
|
||||||
|
"range": {
|
||||||
|
"start": {
|
||||||
|
"line": 1,
|
||||||
|
"character": 20
|
||||||
|
},
|
||||||
|
"end": {
|
||||||
|
"line": 1,
|
||||||
|
"character": 20
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"text": "/~/"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let (method, _) = client.read_notification::<Value>().unwrap();
|
||||||
|
assert_eq!(method, "textDocument/publishDiagnostics");
|
||||||
|
let (method, _) = client.read_notification::<Value>().unwrap();
|
||||||
|
assert_eq!(method, "textDocument/publishDiagnostics");
|
||||||
|
let (method, _) = client.read_notification::<Value>().unwrap();
|
||||||
|
assert_eq!(method, "textDocument/publishDiagnostics");
|
||||||
|
|
||||||
|
let (maybe_res, maybe_err) = client
|
||||||
|
.write_request(
|
||||||
|
"textDocument/completion",
|
||||||
|
json!({
|
||||||
|
"textDocument": {
|
||||||
|
"uri": uri
|
||||||
|
},
|
||||||
|
"position": {
|
||||||
|
"line": 1,
|
||||||
|
"character": 23
|
||||||
|
},
|
||||||
|
"context": {
|
||||||
|
"triggerKind": 2,
|
||||||
|
"triggerCharacter": "/"
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(maybe_err.is_none());
|
||||||
|
assert_eq!(
|
||||||
|
maybe_res,
|
||||||
|
Some(json!({
|
||||||
|
"isIncomplete": false,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"label": "b.ts",
|
||||||
|
"kind": 9,
|
||||||
|
"detail": "(import map)",
|
||||||
|
"sortText": "1",
|
||||||
|
"filterText": "/~/b.ts",
|
||||||
|
"textEdit": {
|
||||||
|
"range": {
|
||||||
|
"start": {
|
||||||
|
"line": 1,
|
||||||
|
"character": 20
|
||||||
|
},
|
||||||
|
"end": {
|
||||||
|
"line": 1,
|
||||||
|
"character": 23
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"newText": "/~/b.ts"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
shutdown(&mut client);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn lsp_hover() {
|
fn lsp_hover() {
|
||||||
let mut client = init("initialize_params.json");
|
let mut client = init("initialize_params.json");
|
||||||
|
|
7
cli/tests/testdata/lsp/import-map-completions.json
vendored
Normal file
7
cli/tests/testdata/lsp/import-map-completions.json
vendored
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"imports": {
|
||||||
|
"/~/": "./lib/",
|
||||||
|
"fs": "https://example.com/fs/index.js",
|
||||||
|
"std/": "https://example.com/std@0.123.0/"
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue