0
0
Fork 0
mirror of https://github.com/denoland/deno.git synced 2025-03-03 17:34:47 -05:00

fix(lsp): complete exports for import mapped jsr specifiers (#24054)

This commit is contained in:
Nayeem Rahman 2024-06-03 21:32:28 +01:00 committed by GitHub
parent 13924fdb1b
commit 72088f2f52
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 557 additions and 477 deletions

View file

@ -9,17 +9,19 @@ use super::jsr::CliJsrSearchApi;
use super::lsp_custom;
use super::npm::CliNpmSearchApi;
use super::registries::ModuleRegistry;
use super::resolver::LspResolver;
use super::search::PackageSearchApi;
use super::tsc;
use crate::jsr::JsrFetchResolver;
use crate::util::path::is_importable_ext;
use crate::util::path::relative_specifier;
use deno_graph::source::ResolutionMode;
use deno_graph::Range;
use deno_runtime::fs_util::specifier_to_file_path;
use deno_ast::LineAndColumnIndex;
use deno_ast::SourceTextInfo;
use deno_core::normalize_path;
use deno_core::resolve_path;
use deno_core::resolve_url;
use deno_core::serde::Deserialize;
@ -30,6 +32,8 @@ use deno_core::ModuleSpecifier;
use deno_semver::jsr::JsrPackageReqReference;
use deno_semver::package::PackageNv;
use import_map::ImportMap;
use indexmap::IndexSet;
use lsp_types::CompletionList;
use once_cell::sync::Lazy;
use regex::Regex;
use tower_lsp::lsp_types as lsp;
@ -154,46 +158,53 @@ pub async fn get_import_completions(
jsr_search_api: &CliJsrSearchApi,
npm_search_api: &CliNpmSearchApi,
documents: &Documents,
resolver: &LspResolver,
maybe_import_map: Option<&ImportMap>,
) -> Option<lsp::CompletionResponse> {
let document = documents.get(specifier)?;
let file_referrer = document.file_referrer();
let (text, _, range) = document.get_maybe_dependency(position)?;
let range = to_narrow_lsp_range(&document.text_info(), &range);
if let Some(completion_list) = get_import_map_completions(
specifier,
&text,
&range,
maybe_import_map,
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)?,
}))
} else if text.starts_with("jsr:") {
let items = get_jsr_completions(
let resolved = resolver
.as_graph_resolver(file_referrer)
.resolve(
&text,
&Range {
specifier: specifier.clone(),
start: deno_graph::Position::zeroed(),
end: deno_graph::Position::zeroed(),
},
ResolutionMode::Execution,
)
.ok();
if let Some(completion_list) = get_jsr_completions(
specifier,
&text,
&range,
resolved.as_ref(),
jsr_search_api,
Some(jsr_search_api.get_resolver()),
)
.await?;
Some(lsp::CompletionResponse::List(lsp::CompletionList {
is_incomplete: !items.is_empty(),
items,
}))
} else if text.starts_with("npm:") {
let items =
get_npm_completions(specifier, &text, &range, npm_search_api).await?;
Some(lsp::CompletionResponse::List(lsp::CompletionList {
is_incomplete: !items.is_empty(),
items,
.await
{
Some(lsp::CompletionResponse::List(completion_list))
} else if let Some(completion_list) =
get_npm_completions(specifier, &text, &range, npm_search_api).await
{
Some(lsp::CompletionResponse::List(completion_list))
} else if let Some(completion_list) =
get_import_map_completions(specifier, &text, &range, maybe_import_map)
{
// completions for import map specifiers
Some(lsp::CompletionResponse::List(completion_list))
} else if text.starts_with("./")
|| text.starts_with("../")
|| text.starts_with('/')
{
// completions for local relative modules
Some(lsp::CompletionResponse::List(CompletionList {
is_incomplete: false,
items: get_local_completions(specifier, &text, &range, resolver)?,
}))
} else if !text.is_empty() {
// completion of modules from a module registry or cache
@ -214,7 +225,7 @@ pub async fn get_import_completions(
documents.exists(s, file_referrer)
})
.await;
let list = maybe_list.unwrap_or_else(|| lsp::CompletionList {
let list = maybe_list.unwrap_or_else(|| CompletionList {
items: get_workspace_completions(specifier, &text, &range, documents),
is_incomplete: false,
});
@ -246,7 +257,7 @@ pub async fn get_import_completions(
is_incomplete = origin_items.is_incomplete;
items.extend(origin_items.items);
}
Some(lsp::CompletionResponse::List(lsp::CompletionList {
Some(lsp::CompletionResponse::List(CompletionList {
is_incomplete,
items,
}))
@ -298,15 +309,14 @@ fn get_base_import_map_completions(
/// 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,
_specifier: &ModuleSpecifier,
text: &str,
range: &lsp::Range,
maybe_import_map: Option<&ImportMap>,
documents: &Documents,
) -> Option<lsp::CompletionList> {
) -> Option<CompletionList> {
if !text.is_empty() {
if let Some(import_map) = maybe_import_map {
let mut items = Vec::new();
let mut specifiers = IndexSet::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
@ -315,101 +325,67 @@ fn get_import_map_completions(
} 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(DocumentsFilter::AllDiagnosable)
if key.starts_with(text) && key != text {
specifiers.insert(key.trim_end_matches('/').to_string());
}
}
if !specifiers.is_empty() {
let items = specifiers
.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),
.map(|specifier| lsp::CompletionItem {
label: specifier.clone(),
kind: Some(lsp::CompletionItemKind::FILE),
detail: Some("(import map)".to_string()),
sort_text: Some("1".to_string()),
filter_text: Some(new_text),
text_edit,
text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit {
range: *range,
new_text: specifier,
})),
commit_characters: Some(
IMPORT_COMMIT_CHARS.iter().map(|&c| c.into()).collect(),
),
..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,
kind,
detail: Some("(import map)".to_string()),
sort_text: Some("1".to_string()),
text_edit,
commit_characters: Some(
IMPORT_COMMIT_CHARS.iter().map(|&c| c.into()).collect(),
),
..Default::default()
});
}
if !items.is_empty() {
return Some(lsp::CompletionList {
return Some(CompletionList {
items,
is_incomplete: false,
});
}
}
}
}
None
}
/// Return local completions that are relative to the base specifier.
fn get_local_completions(
base: &ModuleSpecifier,
current: &str,
text: &str,
range: &lsp::Range,
resolver: &LspResolver,
) -> Option<Vec<lsp::CompletionItem>> {
if base.scheme() != "file" {
return None;
}
let mut base_path = specifier_to_file_path(base).ok()?;
base_path.pop();
let mut current_path = normalize_path(base_path.join(current));
// if the current text does not end in a `/` then we are still selecting on
// the parent and should show all completions from there.
let is_parent = if !current.ends_with('/') {
current_path.pop();
true
} else {
false
};
let parent = base.join(text).ok()?.join(".").ok()?;
let resolved_parent = resolver
.as_graph_resolver(Some(base))
.resolve(
parent.as_str(),
&Range {
specifier: base.clone(),
start: deno_graph::Position::zeroed(),
end: deno_graph::Position::zeroed(),
},
ResolutionMode::Execution,
)
.ok()?;
let resolved_parent_path = specifier_to_file_path(&resolved_parent).ok()?;
let raw_parent =
&text[..text.char_indices().rfind(|(_, c)| *c == '/')?.0 + 1];
if resolved_parent_path.is_dir() {
let cwd = std::env::current_dir().ok()?;
if current_path.is_dir() {
let items = std::fs::read_dir(current_path).ok()?;
let items = std::fs::read_dir(resolved_parent_path).ok()?;
Some(
items
.filter_map(|de| {
@ -419,27 +395,17 @@ fn get_local_completions(
if entry_specifier == *base {
return None;
}
let full_text = relative_specifier(base, &entry_specifier)?;
// this weeds out situations where we are browsing in the parent, but
// we want to filter out non-matches when the completion is manually
// invoked by the user, but still allows for things like `../src/../`
// which is silly, but no reason to not allow it.
if is_parent && !full_text.starts_with(current) {
return None;
}
let full_text = format!("{raw_parent}{label}");
let text_edit = Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit {
range: *range,
new_text: full_text.clone(),
}));
let filter_text = if full_text.starts_with(current) {
Some(full_text)
} else {
Some(format!("{current}{label}"))
};
let filter_text = Some(full_text);
match de.file_type() {
Ok(file_type) if file_type.is_dir() => Some(lsp::CompletionItem {
label,
kind: Some(lsp::CompletionItemKind::FOLDER),
detail: Some("(local)".to_string()),
filter_text,
sort_text: Some("1".to_string()),
text_edit,
@ -517,11 +483,15 @@ async fn get_jsr_completions(
referrer: &ModuleSpecifier,
specifier: &str,
range: &lsp::Range,
resolved: Option<&ModuleSpecifier>,
jsr_search_api: &impl PackageSearchApi,
jsr_resolver: Option<&JsrFetchResolver>,
) -> Option<Vec<lsp::CompletionItem>> {
) -> Option<CompletionList> {
// First try to match `jsr:some-package@some-version/<export-to-complete>`.
if let Ok(req_ref) = JsrPackageReqReference::from_str(specifier) {
let req_ref = resolved
.and_then(|s| JsrPackageReqReference::from_specifier(s).ok())
.or_else(|| JsrPackageReqReference::from_str(specifier).ok());
if let Some(req_ref) = req_ref {
let sub_path = req_ref.sub_path();
if sub_path.is_some() || specifier.ends_with('/') {
let export_prefix = sub_path.unwrap_or("");
@ -543,7 +513,10 @@ async fn get_jsr_completions(
if !export.starts_with(export_prefix) {
return None;
}
let specifier = format!("jsr:{}/{}", req_ref.req(), export);
let specifier = format!(
"{}/{export}",
specifier.strip_suffix(export_prefix)?.trim_end_matches('/')
);
let command = Some(lsp::Command {
title: "".to_string(),
command: "deno.cache".to_string(),
@ -571,7 +544,10 @@ async fn get_jsr_completions(
})
})
.collect();
return Some(items);
return Some(CompletionList {
is_incomplete: false,
items,
});
}
}
@ -618,7 +594,10 @@ async fn get_jsr_completions(
})
})
.collect();
return Some(items);
return Some(CompletionList {
is_incomplete: false,
items,
});
}
// Otherwise match `jsr:<package-to-complete>`.
@ -655,7 +634,10 @@ async fn get_jsr_completions(
}
})
.collect();
Some(items)
Some(CompletionList {
is_incomplete: true,
items,
})
}
/// Get completions for `npm:` specifiers.
@ -664,7 +646,7 @@ async fn get_npm_completions(
specifier: &str,
range: &lsp::Range,
npm_search_api: &impl PackageSearchApi,
) -> Option<Vec<lsp::CompletionItem>> {
) -> Option<CompletionList> {
// First try to match `npm:some-package@<version-to-complete>`.
let bare_specifier = specifier.strip_prefix("npm:")?;
if let Some(v_index) = parse_bare_specifier_version_index(bare_specifier) {
@ -707,7 +689,10 @@ async fn get_npm_completions(
})
})
.collect();
return Some(items);
return Some(CompletionList {
is_incomplete: false,
items,
});
}
// Otherwise match `npm:<package-to-complete>`.
@ -744,7 +729,10 @@ async fn get_npm_completions(
}
})
.collect();
Some(items)
Some(CompletionList {
is_incomplete: true,
items,
})
}
/// Get workspace completions that include modules in the Deno cache which match
@ -807,6 +795,7 @@ mod tests {
use crate::lsp::search::tests::TestPackageSearchApi;
use deno_core::resolve_url;
use deno_graph::Range;
use pretty_assertions::assert_eq;
use std::collections::HashMap;
use test_util::TempDir;
@ -897,6 +886,7 @@ mod tests {
character: 22,
},
},
&Default::default(),
);
assert!(actual.is_some());
let actual = actual.unwrap();
@ -1012,13 +1002,21 @@ mod tests {
},
};
let referrer = ModuleSpecifier::parse("file:///referrer.ts").unwrap();
let actual =
get_jsr_completions(&referrer, "jsr:as", &range, &jsr_search_api, None)
let actual = get_jsr_completions(
&referrer,
"jsr:as",
&range,
None,
&jsr_search_api,
None,
)
.await
.unwrap();
assert_eq!(
actual,
vec![
CompletionList {
is_incomplete: true,
items: vec![
lsp::CompletionItem {
label: "jsr:@std/assert".to_string(),
kind: Some(lsp::CompletionItemKind::FILE),
@ -1065,7 +1063,8 @@ mod tests {
),
..Default::default()
},
]
],
}
);
}
@ -1090,6 +1089,7 @@ mod tests {
&referrer,
"jsr:@std/assert@",
&range,
None,
&jsr_search_api,
None,
)
@ -1097,7 +1097,9 @@ mod tests {
.unwrap();
assert_eq!(
actual,
vec![
CompletionList {
is_incomplete: false,
items: vec![
lsp::CompletionItem {
label: "jsr:@std/assert@0.5.0".to_string(),
kind: Some(lsp::CompletionItemKind::FILE),
@ -1167,7 +1169,8 @@ mod tests {
),
..Default::default()
},
]
],
}
);
}
@ -1193,6 +1196,7 @@ mod tests {
&referrer,
"jsr:@std/path@0.1.0/co",
&range,
None,
&jsr_search_api,
None,
)
@ -1200,7 +1204,9 @@ mod tests {
.unwrap();
assert_eq!(
actual,
vec![
CompletionList {
is_incomplete: false,
items: vec![
lsp::CompletionItem {
label: "jsr:@std/path@0.1.0/common".to_string(),
kind: Some(lsp::CompletionItemKind::FILE),
@ -1247,7 +1253,67 @@ mod tests {
),
..Default::default()
},
]
],
}
);
}
#[tokio::test]
async fn test_get_jsr_completions_for_exports_import_mapped() {
let jsr_search_api = TestPackageSearchApi::default().with_package_version(
"@std/path",
"0.1.0",
&[".", "./common"],
);
let range = lsp::Range {
start: lsp::Position {
line: 0,
character: 23,
},
end: lsp::Position {
line: 0,
character: 45,
},
};
let referrer = ModuleSpecifier::parse("file:///referrer.ts").unwrap();
let actual = get_jsr_completions(
&referrer,
"@std/path/co",
&range,
Some(&ModuleSpecifier::parse("jsr:@std/path@0.1.0/co").unwrap()),
&jsr_search_api,
None,
)
.await
.unwrap();
assert_eq!(
actual,
CompletionList {
is_incomplete: false,
items: vec![lsp::CompletionItem {
label: "@std/path/common".to_string(),
kind: Some(lsp::CompletionItemKind::FILE),
detail: Some("(jsr)".to_string()),
sort_text: Some("0000000002".to_string()),
text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit {
range,
new_text: "@std/path/common".to_string(),
})),
command: Some(lsp::Command {
title: "".to_string(),
command: "deno.cache".to_string(),
arguments: Some(vec![
json!(["@std/path/common"]),
json!(&referrer),
json!({ "forceGlobalCache": true }),
])
}),
commit_characters: Some(
IMPORT_COMMIT_CHARS.iter().map(|&c| c.into()).collect()
),
..Default::default()
},],
}
);
}
@ -1275,7 +1341,9 @@ mod tests {
.unwrap();
assert_eq!(
actual,
vec![
CompletionList {
is_incomplete: true,
items: vec![
lsp::CompletionItem {
label: "npm:puppeteer".to_string(),
kind: Some(lsp::CompletionItemKind::FILE),
@ -1368,7 +1436,8 @@ mod tests {
),
..Default::default()
},
]
],
}
);
}
@ -1396,7 +1465,9 @@ mod tests {
.unwrap();
assert_eq!(
actual,
vec![
CompletionList {
is_incomplete: false,
items: vec![
lsp::CompletionItem {
label: "npm:puppeteer@21.0.2".to_string(),
kind: Some(lsp::CompletionItemKind::FILE),
@ -1489,7 +1560,8 @@ mod tests {
),
..Default::default()
},
]
],
}
);
}

View file

@ -2105,6 +2105,7 @@ impl Inner {
&self.jsr_search_api,
&self.npm_search_api,
&self.documents,
self.resolver.as_ref(),
self.config.tree.root_import_map().map(|i| i.as_ref()),
)
.await;

View file

@ -1224,6 +1224,7 @@ fn lsp_import_map_import_completions() {
r#"{
"imports": {
"/~/": "./lib/",
"/#/": "./src/",
"fs": "https://example.com/fs/index.js",
"std/": "https://example.com/std@0.123.0/"
}
@ -1296,7 +1297,14 @@ fn lsp_import_map_import_completions() {
"sortText": "/~",
"insertText": "/~",
"commitCharacters": ["\"", "'"],
}
}, {
"label": "/#",
"kind": 19,
"detail": "(import map)",
"sortText": "/#",
"insertText": "/#",
"commitCharacters": ["\"", "'"],
},
]
})
);
@ -1335,8 +1343,8 @@ fn lsp_import_map_import_completions() {
"items": [
{
"label": "b.ts",
"kind": 9,
"detail": "(import map)",
"kind": 17,
"detail": "(local)",
"sortText": "1",
"filterText": "/~/b.ts",
"textEdit": {
@ -7938,7 +7946,6 @@ fn lsp_completions_snippet() {
(5, 13),
json!({ "triggerKind": 1 }),
);
assert!(!list.is_incomplete);
assert_eq!(
json!(list),
json!({