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::lsp_custom;
use super::npm::CliNpmSearchApi; use super::npm::CliNpmSearchApi;
use super::registries::ModuleRegistry; use super::registries::ModuleRegistry;
use super::resolver::LspResolver;
use super::search::PackageSearchApi; use super::search::PackageSearchApi;
use super::tsc; use super::tsc;
use crate::jsr::JsrFetchResolver; use crate::jsr::JsrFetchResolver;
use crate::util::path::is_importable_ext; use crate::util::path::is_importable_ext;
use crate::util::path::relative_specifier; 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_runtime::fs_util::specifier_to_file_path;
use deno_ast::LineAndColumnIndex; use deno_ast::LineAndColumnIndex;
use deno_ast::SourceTextInfo; use deno_ast::SourceTextInfo;
use deno_core::normalize_path;
use deno_core::resolve_path; use deno_core::resolve_path;
use deno_core::resolve_url; use deno_core::resolve_url;
use deno_core::serde::Deserialize; use deno_core::serde::Deserialize;
@ -30,6 +32,8 @@ use deno_core::ModuleSpecifier;
use deno_semver::jsr::JsrPackageReqReference; use deno_semver::jsr::JsrPackageReqReference;
use deno_semver::package::PackageNv; use deno_semver::package::PackageNv;
use import_map::ImportMap; use import_map::ImportMap;
use indexmap::IndexSet;
use lsp_types::CompletionList;
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
use regex::Regex; use regex::Regex;
use tower_lsp::lsp_types as lsp; use tower_lsp::lsp_types as lsp;
@ -154,46 +158,53 @@ pub async fn get_import_completions(
jsr_search_api: &CliJsrSearchApi, jsr_search_api: &CliJsrSearchApi,
npm_search_api: &CliNpmSearchApi, npm_search_api: &CliNpmSearchApi,
documents: &Documents, documents: &Documents,
resolver: &LspResolver,
maybe_import_map: Option<&ImportMap>, maybe_import_map: Option<&ImportMap>,
) -> Option<lsp::CompletionResponse> { ) -> Option<lsp::CompletionResponse> {
let document = documents.get(specifier)?; let document = documents.get(specifier)?;
let file_referrer = document.file_referrer(); let file_referrer = document.file_referrer();
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);
if let Some(completion_list) = get_import_map_completions( let resolved = resolver
specifier, .as_graph_resolver(file_referrer)
&text, .resolve(
&range, &text,
maybe_import_map, &Range {
documents, specifier: specifier.clone(),
) { start: deno_graph::Position::zeroed(),
// completions for import map specifiers end: deno_graph::Position::zeroed(),
Some(lsp::CompletionResponse::List(completion_list)) },
} else if text.starts_with("./") || text.starts_with("../") { ResolutionMode::Execution,
// completions for local relative modules )
Some(lsp::CompletionResponse::List(lsp::CompletionList { .ok();
is_incomplete: false, if let Some(completion_list) = get_jsr_completions(
items: get_local_completions(specifier, &text, &range)?,
}))
} else if text.starts_with("jsr:") {
let items = get_jsr_completions(
specifier, specifier,
&text, &text,
&range, &range,
resolved.as_ref(),
jsr_search_api, jsr_search_api,
Some(jsr_search_api.get_resolver()), Some(jsr_search_api.get_resolver()),
) )
.await?; .await
Some(lsp::CompletionResponse::List(lsp::CompletionList { {
is_incomplete: !items.is_empty(), Some(lsp::CompletionResponse::List(completion_list))
items, } else if let Some(completion_list) =
})) get_npm_completions(specifier, &text, &range, npm_search_api).await
} else if text.starts_with("npm:") { {
let items = Some(lsp::CompletionResponse::List(completion_list))
get_npm_completions(specifier, &text, &range, npm_search_api).await?; } else if let Some(completion_list) =
Some(lsp::CompletionResponse::List(lsp::CompletionList { get_import_map_completions(specifier, &text, &range, maybe_import_map)
is_incomplete: !items.is_empty(), {
items, // 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() { } else if !text.is_empty() {
// completion of modules from a module registry or cache // completion of modules from a module registry or cache
@ -214,7 +225,7 @@ pub async fn get_import_completions(
documents.exists(s, file_referrer) documents.exists(s, file_referrer)
}) })
.await; .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), items: get_workspace_completions(specifier, &text, &range, documents),
is_incomplete: false, is_incomplete: false,
}); });
@ -246,7 +257,7 @@ pub async fn get_import_completions(
is_incomplete = origin_items.is_incomplete; is_incomplete = origin_items.is_incomplete;
items.extend(origin_items.items); items.extend(origin_items.items);
} }
Some(lsp::CompletionResponse::List(lsp::CompletionList { Some(lsp::CompletionResponse::List(CompletionList {
is_incomplete, is_incomplete,
items, items,
})) }))
@ -298,15 +309,14 @@ fn get_base_import_map_completions(
/// that the path post the `/` should be appended to resolved specifier. This /// that the path post the `/` should be appended to resolved specifier. This
/// handles both cases, pulling any completions from the workspace completions. /// handles both cases, pulling any completions from the workspace completions.
fn get_import_map_completions( fn get_import_map_completions(
specifier: &ModuleSpecifier, _specifier: &ModuleSpecifier,
text: &str, text: &str,
range: &lsp::Range, range: &lsp::Range,
maybe_import_map: Option<&ImportMap>, maybe_import_map: Option<&ImportMap>,
documents: &Documents, ) -> Option<CompletionList> {
) -> Option<lsp::CompletionList> {
if !text.is_empty() { if !text.is_empty() {
if let Some(import_map) = maybe_import_map { 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 key in import_map.imports().keys() {
// for some reason, the import_map stores keys that begin with `/` as // for some reason, the import_map stores keys that begin with `/` as
// `file:///` in its index, so we have to reverse that here // `file:///` in its index, so we have to reverse that here
@ -315,101 +325,67 @@ fn get_import_map_completions(
} else { } else {
key.to_string() key.to_string()
}; };
if text.starts_with(&key) && key.ends_with('/') { if key.starts_with(text) && key != text {
if let Ok(resolved) = import_map.resolve(&key, specifier) { specifiers.insert(key.trim_end_matches('/').to_string());
let resolved = resolved.to_string(); }
let workspace_items: Vec<lsp::CompletionItem> = documents }
.documents(DocumentsFilter::AllDiagnosable) if !specifiers.is_empty() {
let items = specifiers
.into_iter() .into_iter()
.filter_map(|d| { .map(|specifier| lsp::CompletionItem {
let specifier_str = d.specifier().to_string(); label: specifier.clone(),
let new_text = specifier_str.replace(&resolved, &key); kind: Some(lsp::CompletionItemKind::FILE),
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()), detail: Some("(import map)".to_string()),
sort_text: Some("1".to_string()), sort_text: Some("1".to_string()),
filter_text: Some(new_text), text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit {
text_edit, range: *range,
new_text: specifier,
})),
commit_characters: Some( commit_characters: Some(
IMPORT_COMMIT_CHARS.iter().map(|&c| c.into()).collect(), IMPORT_COMMIT_CHARS.iter().map(|&c| c.into()).collect(),
), ),
..Default::default() ..Default::default()
}) })
} else {
None
}
})
.collect(); .collect();
items.extend(workspace_items); return Some(CompletionList {
}
} 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 {
items, items,
is_incomplete: false, is_incomplete: false,
}); });
} }
} }
} }
}
None 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,
current: &str, text: &str,
range: &lsp::Range, range: &lsp::Range,
resolver: &LspResolver,
) -> Option<Vec<lsp::CompletionItem>> { ) -> Option<Vec<lsp::CompletionItem>> {
if base.scheme() != "file" { if base.scheme() != "file" {
return None; return None;
} }
let parent = base.join(text).ok()?.join(".").ok()?;
let mut base_path = specifier_to_file_path(base).ok()?; let resolved_parent = resolver
base_path.pop(); .as_graph_resolver(Some(base))
let mut current_path = normalize_path(base_path.join(current)); .resolve(
// if the current text does not end in a `/` then we are still selecting on parent.as_str(),
// the parent and should show all completions from there. &Range {
let is_parent = if !current.ends_with('/') { specifier: base.clone(),
current_path.pop(); start: deno_graph::Position::zeroed(),
true end: deno_graph::Position::zeroed(),
} else { },
false 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()?; let cwd = std::env::current_dir().ok()?;
if current_path.is_dir() { let items = std::fs::read_dir(resolved_parent_path).ok()?;
let items = std::fs::read_dir(current_path).ok()?;
Some( Some(
items items
.filter_map(|de| { .filter_map(|de| {
@ -419,27 +395,17 @@ fn get_local_completions(
if entry_specifier == *base { if entry_specifier == *base {
return None; return None;
} }
let full_text = relative_specifier(base, &entry_specifier)?; let full_text = format!("{raw_parent}{label}");
// 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 text_edit = Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { let text_edit = Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit {
range: *range, range: *range,
new_text: full_text.clone(), new_text: full_text.clone(),
})); }));
let filter_text = if full_text.starts_with(current) { let filter_text = Some(full_text);
Some(full_text)
} else {
Some(format!("{current}{label}"))
};
match de.file_type() { match de.file_type() {
Ok(file_type) if file_type.is_dir() => Some(lsp::CompletionItem { Ok(file_type) if file_type.is_dir() => Some(lsp::CompletionItem {
label, label,
kind: Some(lsp::CompletionItemKind::FOLDER), kind: Some(lsp::CompletionItemKind::FOLDER),
detail: Some("(local)".to_string()),
filter_text, filter_text,
sort_text: Some("1".to_string()), sort_text: Some("1".to_string()),
text_edit, text_edit,
@ -517,11 +483,15 @@ async fn get_jsr_completions(
referrer: &ModuleSpecifier, referrer: &ModuleSpecifier,
specifier: &str, specifier: &str,
range: &lsp::Range, range: &lsp::Range,
resolved: Option<&ModuleSpecifier>,
jsr_search_api: &impl PackageSearchApi, jsr_search_api: &impl PackageSearchApi,
jsr_resolver: Option<&JsrFetchResolver>, jsr_resolver: Option<&JsrFetchResolver>,
) -> Option<Vec<lsp::CompletionItem>> { ) -> Option<CompletionList> {
// First try to match `jsr:some-package@some-version/<export-to-complete>`. // 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(); let sub_path = req_ref.sub_path();
if sub_path.is_some() || specifier.ends_with('/') { if sub_path.is_some() || specifier.ends_with('/') {
let export_prefix = sub_path.unwrap_or(""); let export_prefix = sub_path.unwrap_or("");
@ -543,7 +513,10 @@ async fn get_jsr_completions(
if !export.starts_with(export_prefix) { if !export.starts_with(export_prefix) {
return None; 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 { let command = Some(lsp::Command {
title: "".to_string(), title: "".to_string(),
command: "deno.cache".to_string(), command: "deno.cache".to_string(),
@ -571,7 +544,10 @@ async fn get_jsr_completions(
}) })
}) })
.collect(); .collect();
return Some(items); return Some(CompletionList {
is_incomplete: false,
items,
});
} }
} }
@ -618,7 +594,10 @@ async fn get_jsr_completions(
}) })
}) })
.collect(); .collect();
return Some(items); return Some(CompletionList {
is_incomplete: false,
items,
});
} }
// Otherwise match `jsr:<package-to-complete>`. // Otherwise match `jsr:<package-to-complete>`.
@ -655,7 +634,10 @@ async fn get_jsr_completions(
} }
}) })
.collect(); .collect();
Some(items) Some(CompletionList {
is_incomplete: true,
items,
})
} }
/// Get completions for `npm:` specifiers. /// Get completions for `npm:` specifiers.
@ -664,7 +646,7 @@ async fn get_npm_completions(
specifier: &str, specifier: &str,
range: &lsp::Range, range: &lsp::Range,
npm_search_api: &impl PackageSearchApi, npm_search_api: &impl PackageSearchApi,
) -> Option<Vec<lsp::CompletionItem>> { ) -> Option<CompletionList> {
// First try to match `npm:some-package@<version-to-complete>`. // First try to match `npm:some-package@<version-to-complete>`.
let bare_specifier = specifier.strip_prefix("npm:")?; let bare_specifier = specifier.strip_prefix("npm:")?;
if let Some(v_index) = parse_bare_specifier_version_index(bare_specifier) { if let Some(v_index) = parse_bare_specifier_version_index(bare_specifier) {
@ -707,7 +689,10 @@ async fn get_npm_completions(
}) })
}) })
.collect(); .collect();
return Some(items); return Some(CompletionList {
is_incomplete: false,
items,
});
} }
// Otherwise match `npm:<package-to-complete>`. // Otherwise match `npm:<package-to-complete>`.
@ -744,7 +729,10 @@ async fn get_npm_completions(
} }
}) })
.collect(); .collect();
Some(items) Some(CompletionList {
is_incomplete: true,
items,
})
} }
/// Get workspace completions that include modules in the Deno cache which match /// 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 crate::lsp::search::tests::TestPackageSearchApi;
use deno_core::resolve_url; use deno_core::resolve_url;
use deno_graph::Range; use deno_graph::Range;
use pretty_assertions::assert_eq;
use std::collections::HashMap; use std::collections::HashMap;
use test_util::TempDir; use test_util::TempDir;
@ -897,6 +886,7 @@ mod tests {
character: 22, character: 22,
}, },
}, },
&Default::default(),
); );
assert!(actual.is_some()); assert!(actual.is_some());
let actual = actual.unwrap(); let actual = actual.unwrap();
@ -1012,13 +1002,21 @@ mod tests {
}, },
}; };
let referrer = ModuleSpecifier::parse("file:///referrer.ts").unwrap(); let referrer = ModuleSpecifier::parse("file:///referrer.ts").unwrap();
let actual = let actual = get_jsr_completions(
get_jsr_completions(&referrer, "jsr:as", &range, &jsr_search_api, None) &referrer,
"jsr:as",
&range,
None,
&jsr_search_api,
None,
)
.await .await
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
actual, actual,
vec![ CompletionList {
is_incomplete: true,
items: vec![
lsp::CompletionItem { lsp::CompletionItem {
label: "jsr:@std/assert".to_string(), label: "jsr:@std/assert".to_string(),
kind: Some(lsp::CompletionItemKind::FILE), kind: Some(lsp::CompletionItemKind::FILE),
@ -1065,7 +1063,8 @@ mod tests {
), ),
..Default::default() ..Default::default()
}, },
] ],
}
); );
} }
@ -1090,6 +1089,7 @@ mod tests {
&referrer, &referrer,
"jsr:@std/assert@", "jsr:@std/assert@",
&range, &range,
None,
&jsr_search_api, &jsr_search_api,
None, None,
) )
@ -1097,7 +1097,9 @@ mod tests {
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
actual, actual,
vec![ CompletionList {
is_incomplete: false,
items: vec![
lsp::CompletionItem { lsp::CompletionItem {
label: "jsr:@std/assert@0.5.0".to_string(), label: "jsr:@std/assert@0.5.0".to_string(),
kind: Some(lsp::CompletionItemKind::FILE), kind: Some(lsp::CompletionItemKind::FILE),
@ -1167,7 +1169,8 @@ mod tests {
), ),
..Default::default() ..Default::default()
}, },
] ],
}
); );
} }
@ -1193,6 +1196,7 @@ mod tests {
&referrer, &referrer,
"jsr:@std/path@0.1.0/co", "jsr:@std/path@0.1.0/co",
&range, &range,
None,
&jsr_search_api, &jsr_search_api,
None, None,
) )
@ -1200,7 +1204,9 @@ mod tests {
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
actual, actual,
vec![ CompletionList {
is_incomplete: false,
items: vec![
lsp::CompletionItem { lsp::CompletionItem {
label: "jsr:@std/path@0.1.0/common".to_string(), label: "jsr:@std/path@0.1.0/common".to_string(),
kind: Some(lsp::CompletionItemKind::FILE), kind: Some(lsp::CompletionItemKind::FILE),
@ -1247,7 +1253,67 @@ mod tests {
), ),
..Default::default() ..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(); .unwrap();
assert_eq!( assert_eq!(
actual, actual,
vec![ CompletionList {
is_incomplete: true,
items: vec![
lsp::CompletionItem { lsp::CompletionItem {
label: "npm:puppeteer".to_string(), label: "npm:puppeteer".to_string(),
kind: Some(lsp::CompletionItemKind::FILE), kind: Some(lsp::CompletionItemKind::FILE),
@ -1368,7 +1436,8 @@ mod tests {
), ),
..Default::default() ..Default::default()
}, },
] ],
}
); );
} }
@ -1396,7 +1465,9 @@ mod tests {
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
actual, actual,
vec![ CompletionList {
is_incomplete: false,
items: vec![
lsp::CompletionItem { lsp::CompletionItem {
label: "npm:puppeteer@21.0.2".to_string(), label: "npm:puppeteer@21.0.2".to_string(),
kind: Some(lsp::CompletionItemKind::FILE), kind: Some(lsp::CompletionItemKind::FILE),
@ -1489,7 +1560,8 @@ mod tests {
), ),
..Default::default() ..Default::default()
}, },
] ],
}
); );
} }

View file

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

View file

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