From 07540e83f2040a25a810ef4f5c402add4dd68c8b Mon Sep 17 00:00:00 2001 From: Kitson Kelly Date: Tue, 8 Feb 2022 15:31:00 +1100 Subject: [PATCH] feat(lsp): provide completions from import map if available Closes #13619 --- Cargo.lock | 4 +- cli/Cargo.toml | 2 +- cli/lsp/completions.rs | 146 +++++++++++++- cli/lsp/language_server.rs | 1 + cli/proc_state.rs | 3 +- cli/tests/integration/lsp_tests.rs | 190 ++++++++++++++++++ .../testdata/lsp/import-map-completions.json | 7 + 7 files changed, 346 insertions(+), 7 deletions(-) create mode 100644 cli/tests/testdata/lsp/import-map-completions.json diff --git a/Cargo.lock b/Cargo.lock index 695e9ba7cb42b6..49d478a00be609 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 2001a1d44f141d..1943a635f35f5b 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -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"] } diff --git a/cli/lsp/completions.rs b/cli/lsp/completions.rs index c3026697ae5d5b..5c6b37b7ac799b 100644 --- a/cli/lsp/completions.rs +++ b/cli/lsp/completions.rs @@ -21,7 +21,9 @@ 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 std::sync::Arc; const CURRENT_PATH: &str = "."; const PARENT_PATH: &str = ".."; @@ -126,12 +128,22 @@ pub(crate) async fn get_import_completions( client: Client, module_registries: &ModuleRegistry, documents: &Documents, + maybe_import_map: Option>, ) -> Option { 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 +167,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 = LOCAL_PATHS .iter() .map(|s| lsp::CompletionItem { @@ -167,6 +181,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 +194,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 { + 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://") { + key.replace("file://", "") + } 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>, + documents: &Documents, +) -> Option { + 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://") { + key.replace("file://", "") + } 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 = 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, diff --git a/cli/lsp/language_server.rs b/cli/lsp/language_server.rs index bf7be3ea7978e6..e561e5d4eb56f2 100644 --- a/cli/lsp/language_server.rs +++ b/cli/lsp/language_server.rs @@ -1649,6 +1649,7 @@ impl Inner { self.client.clone(), &self.module_registries, &self.documents, + self.maybe_import_map.clone(), ) .await { diff --git a/cli/proc_state.rs b/cli/proc_state.rs index 320e20ac4d2180..5b347d169d6324 100644 --- a/cli/proc_state.rs +++ b/cli/proc_state.rs @@ -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 { - 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{}", diff --git a/cli/tests/integration/lsp_tests.rs b/cli/tests/integration/lsp_tests.rs index 7607582c81e426..d45b9095594c9b 100644 --- a/cli/tests/integration/lsp_tests.rs +++ b/cli/tests/integration/lsp_tests.rs @@ -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::().unwrap(); + assert_eq!(method, "textDocument/publishDiagnostics"); + let (method, _) = client.read_notification::().unwrap(); + assert_eq!(method, "textDocument/publishDiagnostics"); + let (method, _) = client.read_notification::().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"); diff --git a/cli/tests/testdata/lsp/import-map-completions.json b/cli/tests/testdata/lsp/import-map-completions.json new file mode 100644 index 00000000000000..f2275222af61d1 --- /dev/null +++ b/cli/tests/testdata/lsp/import-map-completions.json @@ -0,0 +1,7 @@ +{ + "imports": { + "/~/": "./lib/", + "fs": "https://example.com/fs/index.js", + "std/": "https://example.com/std@0.123.0/" + } +}