mirror of
https://github.com/denoland/deno.git
synced 2024-12-26 17:19:06 -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]]
|
||||
name = "import_map"
|
||||
version = "0.6.0"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f64f821df8ee00a0fba2dde6296af519eff7d823542b057c1b8c40ca1d58f4c"
|
||||
checksum = "09ae88504e9128c4c181a0a4726d868d52aa76de270c7fb00c3c40a8f4fbace4"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"log",
|
||||
|
|
|
@ -68,7 +68,7 @@ encoding_rs = "=0.8.29"
|
|||
env_logger = "=0.8.4"
|
||||
fancy-regex = "=0.7.1"
|
||||
http = "=0.2.4"
|
||||
import_map = "=0.6.0"
|
||||
import_map = "=0.8.0"
|
||||
jsonc-parser = { version = "=0.19.0", features = ["serde"] }
|
||||
libc = "=0.2.106"
|
||||
log = { version = "=0.4.14", features = ["serde"] }
|
||||
|
|
|
@ -21,7 +21,14 @@ use deno_core::serde::Deserialize;
|
|||
use deno_core::serde::Serialize;
|
||||
use deno_core::url::Position;
|
||||
use deno_core::ModuleSpecifier;
|
||||
use import_map::ImportMap;
|
||||
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 PARENT_PATH: &str = "..";
|
||||
|
@ -126,12 +133,22 @@ pub(crate) async fn get_import_completions(
|
|||
client: Client,
|
||||
module_registries: &ModuleRegistry,
|
||||
documents: &Documents,
|
||||
maybe_import_map: Option<Arc<ImportMap>>,
|
||||
) -> Option<lsp::CompletionResponse> {
|
||||
let document = documents.get(specifier)?;
|
||||
let (text, _, range) = document.get_maybe_dependency(position)?;
|
||||
let range = to_narrow_lsp_range(&document.text_info(), &range);
|
||||
// completions for local relative modules
|
||||
if text.starts_with("./") || text.starts_with("../") {
|
||||
if let Some(completion_list) = get_import_map_completions(
|
||||
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 {
|
||||
is_incomplete: false,
|
||||
items: get_local_completions(specifier, &text, &range)?,
|
||||
|
@ -155,6 +172,8 @@ pub(crate) async fn get_import_completions(
|
|||
});
|
||||
Some(lsp::CompletionResponse::List(list))
|
||||
} else {
|
||||
// the import specifier is empty, so provide all possible specifiers we are
|
||||
// aware of
|
||||
let mut items: Vec<lsp::CompletionItem> = LOCAL_PATHS
|
||||
.iter()
|
||||
.map(|s| lsp::CompletionItem {
|
||||
|
@ -167,6 +186,9 @@ pub(crate) async fn get_import_completions(
|
|||
})
|
||||
.collect();
|
||||
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) =
|
||||
module_registries.get_origin_completions(&text, &range)
|
||||
{
|
||||
|
@ -177,10 +199,133 @@ pub(crate) async fn get_import_completions(
|
|||
is_incomplete,
|
||||
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.
|
||||
fn get_local_completions(
|
||||
base: &ModuleSpecifier,
|
||||
|
|
|
@ -1649,6 +1649,7 @@ impl Inner {
|
|||
self.client.clone(),
|
||||
&self.module_registries,
|
||||
&self.documents,
|
||||
self.maybe_import_map.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
|
|
@ -49,6 +49,7 @@ use deno_runtime::deno_tls::rustls::RootCertStore;
|
|||
use deno_runtime::deno_web::BlobStore;
|
||||
use deno_runtime::inspector_server::InspectorServer;
|
||||
use deno_runtime::permissions::Permissions;
|
||||
use import_map::parse_from_json;
|
||||
use import_map::ImportMap;
|
||||
use log::warn;
|
||||
use std::collections::HashSet;
|
||||
|
@ -617,7 +618,7 @@ pub fn import_map_from_text(
|
|||
specifier: &Url,
|
||||
json_text: &str,
|
||||
) -> 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() {
|
||||
warn!(
|
||||
"Import map diagnostics:\n{}",
|
||||
|
|
|
@ -547,6 +547,196 @@ fn lsp_import_assertions() {
|
|||
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]
|
||||
fn lsp_hover() {
|
||||
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