mirror of
https://github.com/denoland/deno.git
synced 2024-12-22 07:14:47 -05:00
feat(lsp): improve registry completion suggestions (#13023)
Resolves #10051
This commit is contained in:
parent
8fdade79da
commit
a54fc7a129
11 changed files with 542 additions and 232 deletions
|
@ -5,176 +5,5 @@ The Deno Language Server provides a server implementation of the
|
|||
which is specifically tailored to provide a _Deno_ view of code. It is
|
||||
integrated into the command line and can be started via the `lsp` sub-command.
|
||||
|
||||
> :warning: The Language Server is experimental and not feature complete. This
|
||||
> document gives an overview of the structure of the language server.
|
||||
|
||||
## Structure
|
||||
|
||||
When the language server is started, a `LanguageServer` instance is created
|
||||
which holds all of the state of the language server. It also defines all of the
|
||||
methods that the client calls via the Language Server RPC protocol.
|
||||
|
||||
## Settings
|
||||
|
||||
There are several settings that the language server supports for a workspace:
|
||||
|
||||
- `deno.enable`
|
||||
- `deno.cache`
|
||||
- `deno.config`
|
||||
- `deno.importMap`
|
||||
- `deno.internalDebug`
|
||||
- `deno.codeLens.implementations`
|
||||
- `deno.codeLens.references`
|
||||
- `deno.codeLens.referencesAllFunctions`
|
||||
- `deno.codeLens.test`
|
||||
- `deno.suggest.completeFunctionCalls`
|
||||
- `deno.suggest.names`
|
||||
- `deno.suggest.paths`
|
||||
- `deno.suggest.autoImports`
|
||||
- `deno.suggest.imports.autoDiscover`
|
||||
- `deno.suggest.imports.hosts`
|
||||
- `deno.lint`
|
||||
- `deno.unstable`
|
||||
|
||||
There are settings that are supported on a per resource basis by the language
|
||||
server:
|
||||
|
||||
- `deno.enable`
|
||||
- `deno.codeLens.test`
|
||||
|
||||
There are several points in the process where Deno analyzes these settings.
|
||||
First, when the `initialize` request from the client, the
|
||||
`initializationOptions` will be assumed to be an object that represents the
|
||||
`deno` namespace of options. For example, the following value:
|
||||
|
||||
```json
|
||||
{
|
||||
"enable": true,
|
||||
"unstable": true
|
||||
}
|
||||
```
|
||||
|
||||
Would enable Deno with the unstable APIs for this instance of the language
|
||||
server.
|
||||
|
||||
When the language server receives a `workspace/didChangeConfiguration`
|
||||
notification, it will assess if the client has indicated if it has a
|
||||
`workspaceConfiguration` capability. If it does, it will send a
|
||||
`workspace/configuration` request which will include a request for the workspace
|
||||
configuration as well as the configuration of all URIs that the language server
|
||||
is currently tracking.
|
||||
|
||||
If the client has the `workspaceConfiguration` capability, the language server
|
||||
will send a configuration request for the URI when it received the
|
||||
`textDocument/didOpen` notification in order to get the resources specific
|
||||
settings.
|
||||
|
||||
If the client does not have the `workspaceConfiguration` capability, the
|
||||
language server will assume the workspace setting applies to all resources.
|
||||
|
||||
## Commands
|
||||
|
||||
There are several commands that might be issued by the language server to the
|
||||
client, which the client is expected to implement:
|
||||
|
||||
- `deno.cache` - This is sent as a resolution code action when there is an
|
||||
un-cached module specifier that is being imported into a module. It will be
|
||||
sent with and argument that contains the resolved specifier as a string to be
|
||||
cached.
|
||||
- `deno.showReferences` - This is sent as the command on some code lenses to
|
||||
show locations of references. The arguments contain the specifier that is the
|
||||
subject of the command, the start position of the target and the locations of
|
||||
the references to show.
|
||||
- `deno.test` - This is sent as part of a test code lens to, of which the client
|
||||
is expected to run a test based on the arguments, which are the specifier the
|
||||
test is contained in and the name of the test to filter the tests on.
|
||||
|
||||
## Requests
|
||||
|
||||
The LSP currently supports the following custom requests. A client should
|
||||
implement these in order to have a fully functioning client that integrates well
|
||||
with Deno:
|
||||
|
||||
- `deno/cache` - This command will instruct Deno to attempt to cache a module
|
||||
and all of its dependencies. If a `referrer` only is passed, then all
|
||||
dependencies for the module specifier will be loaded. If there are values in
|
||||
the `uris`, then only those `uris` will be cached.
|
||||
|
||||
It expects parameters of:
|
||||
|
||||
```ts
|
||||
interface CacheParams {
|
||||
referrer: TextDocumentIdentifier;
|
||||
uris: TextDocumentIdentifier[];
|
||||
}
|
||||
```
|
||||
- `deno/performance` - Requests the return of the timing averages for the
|
||||
internal instrumentation of Deno.
|
||||
|
||||
It does not expect any parameters.
|
||||
- `deno/reloadImportRegistries` - Reloads any cached responses from import
|
||||
registries.
|
||||
|
||||
It does not expect any parameters.
|
||||
- `deno/virtualTextDocument` - Requests a virtual text document from the LSP,
|
||||
which is a read only document that can be displayed in the client. This allows
|
||||
clients to access documents in the Deno cache, like remote modules and
|
||||
TypeScript library files built into Deno. The Deno language server will encode
|
||||
all internal files under the custom schema `deno:`, so clients should route
|
||||
all requests for the `deno:` schema back to the `deno/virtualTextDocument`
|
||||
API.
|
||||
|
||||
It also supports a special URL of `deno:/status.md` which provides a markdown
|
||||
formatted text document that contains details about the status of the LSP for
|
||||
display to a user.
|
||||
|
||||
It expects parameters of:
|
||||
|
||||
```ts
|
||||
interface VirtualTextDocumentParams {
|
||||
textDocument: TextDocumentIdentifier;
|
||||
}
|
||||
```
|
||||
|
||||
## Notifications
|
||||
|
||||
There is currently one custom notification that is sent from the server to the
|
||||
client:
|
||||
|
||||
- `deno/registryState` - when `deno.suggest.imports.autoDiscover` is `true` and
|
||||
an origin for an import being added to a document is not explicitly set in
|
||||
`deno.suggest.imports.hosts`, the origin will be checked and the notification
|
||||
will be sent to the client of the status.
|
||||
|
||||
When receiving the notification, if the param `suggestion` is `true`, the
|
||||
client should offer the user the choice to enable the origin and add it to the
|
||||
configuration for `deno.suggest.imports.hosts`. If `suggestion` is `false` the
|
||||
client should add it to the configuration of as `false` to stop the language
|
||||
server from attempting to detect if suggestions are supported.
|
||||
|
||||
The params for the notification are:
|
||||
|
||||
```ts
|
||||
interface RegistryStatusNotificationParams {
|
||||
origin: string;
|
||||
suggestions: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
## Language IDs
|
||||
|
||||
The language server supports diagnostics and formatting for the following
|
||||
[text document language IDs](https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocumentItem):
|
||||
|
||||
- `"javascript"`
|
||||
- `"javascriptreact"`
|
||||
- `"jsx"` _non standard, same as `javascriptreact`_
|
||||
- `"typescript"`
|
||||
- `"typescriptreact"`
|
||||
- `"tsx"` _non standard, same as `typescriptreact`_
|
||||
|
||||
The language server supports only formatting for the following language IDs:
|
||||
|
||||
- `"json"`
|
||||
- `"jsonc"`
|
||||
- `"markdown"`
|
||||
This documentation has been moved to the
|
||||
[Deno manual](https://deno.land/manual/language_server/overview).
|
||||
|
|
|
@ -23,6 +23,8 @@ const LOCAL_PATHS: &[&str] = &[CURRENT_PATH, PARENT_PATH];
|
|||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CompletionItemData {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub documentation: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tsc: Option<tsc::CompletionItemData>,
|
||||
}
|
||||
|
@ -132,19 +134,22 @@ pub(crate) async fn get_import_completions(
|
|||
} else {
|
||||
0
|
||||
};
|
||||
let maybe_items = state_snapshot
|
||||
let maybe_list = state_snapshot
|
||||
.module_registries
|
||||
.get_completions(&text, offset, &range, |specifier| {
|
||||
state_snapshot.documents.contains_specifier(specifier)
|
||||
})
|
||||
.await;
|
||||
let items = maybe_items.unwrap_or_else(|| {
|
||||
get_workspace_completions(specifier, &text, &range, state_snapshot)
|
||||
});
|
||||
Some(lsp::CompletionResponse::List(lsp::CompletionList {
|
||||
let list = maybe_list.unwrap_or_else(|| lsp::CompletionList {
|
||||
items: get_workspace_completions(
|
||||
specifier,
|
||||
&text,
|
||||
&range,
|
||||
state_snapshot,
|
||||
),
|
||||
is_incomplete: false,
|
||||
items,
|
||||
}))
|
||||
});
|
||||
Some(lsp::CompletionResponse::List(list))
|
||||
} else {
|
||||
let mut items: Vec<lsp::CompletionItem> = LOCAL_PATHS
|
||||
.iter()
|
||||
|
@ -157,14 +162,16 @@ pub(crate) async fn get_import_completions(
|
|||
..Default::default()
|
||||
})
|
||||
.collect();
|
||||
let mut is_incomplete = false;
|
||||
if let Some(origin_items) = state_snapshot
|
||||
.module_registries
|
||||
.get_origin_completions(&text, &range)
|
||||
{
|
||||
items.extend(origin_items);
|
||||
is_incomplete = origin_items.is_incomplete;
|
||||
items.extend(origin_items.items);
|
||||
}
|
||||
Some(lsp::CompletionResponse::List(lsp::CompletionList {
|
||||
is_incomplete: false,
|
||||
is_incomplete,
|
||||
items,
|
||||
}))
|
||||
// TODO(@kitsonk) add bare specifiers from import map
|
||||
|
|
|
@ -1778,6 +1778,12 @@ impl Inner {
|
|||
);
|
||||
params
|
||||
}
|
||||
} else if let Some(url) = data.documentation {
|
||||
CompletionItem {
|
||||
documentation: self.module_registries.get_documentation(&url).await,
|
||||
data: None,
|
||||
..params
|
||||
}
|
||||
} else {
|
||||
params
|
||||
}
|
||||
|
|
|
@ -22,6 +22,8 @@ use deno_core::resolve_url;
|
|||
use deno_core::serde::Deserialize;
|
||||
use deno_core::serde_json;
|
||||
use deno_core::serde_json::json;
|
||||
use deno_core::serde_json::Value;
|
||||
use deno_core::url::ParseError;
|
||||
use deno_core::url::Position;
|
||||
use deno_core::url::Url;
|
||||
use deno_core::ModuleSpecifier;
|
||||
|
@ -135,23 +137,60 @@ fn get_completor_type(
|
|||
None
|
||||
}
|
||||
|
||||
/// Convert a completion URL string from a completions configuration into a
|
||||
/// fully qualified URL which can be fetched to provide the completions.
|
||||
fn get_completion_endpoint(
|
||||
/// Generate a data value for a completion item that will instruct the client to
|
||||
/// resolve the completion item to obtain further information, in this case, the
|
||||
/// details/documentation endpoint for the item if it exists in the registry
|
||||
/// configuration
|
||||
fn get_data(
|
||||
registry: &RegistryConfiguration,
|
||||
base: &ModuleSpecifier,
|
||||
variable: &Key,
|
||||
value: &str,
|
||||
) -> Option<Value> {
|
||||
let url = registry.get_documentation_url_for_key(variable)?;
|
||||
get_endpoint(url, base, variable, Some(value))
|
||||
.ok()
|
||||
.map(|specifier| json!({ "documentation": specifier }))
|
||||
}
|
||||
|
||||
/// Convert a single variable templated string into a fully qualified URL which
|
||||
/// can be fetched to provide additional data.
|
||||
fn get_endpoint(
|
||||
url: &str,
|
||||
base: &Url,
|
||||
variable: &Key,
|
||||
maybe_value: Option<&str>,
|
||||
) -> Result<ModuleSpecifier, AnyError> {
|
||||
let url = replace_variable(url, variable, maybe_value);
|
||||
parse_url_with_base(&url, base)
|
||||
}
|
||||
|
||||
/// Convert a templated URL string into a fully qualified URL which can be
|
||||
/// fetched to provide additional data. If `maybe_value` is some, then the
|
||||
/// variable will replaced in the template prior to other matched variables
|
||||
/// being replaced, otherwise the supplied variable will be blanked out if
|
||||
/// present in the template.
|
||||
fn get_endpoint_with_match(
|
||||
variable: &Key,
|
||||
url: &str,
|
||||
base: &Url,
|
||||
tokens: &[Token],
|
||||
match_result: &MatchResult,
|
||||
maybe_value: Option<&str>,
|
||||
) -> Result<ModuleSpecifier, AnyError> {
|
||||
let mut url_str = url.to_string();
|
||||
let mut url = url.to_string();
|
||||
let has_value = maybe_value.is_some();
|
||||
if has_value {
|
||||
url = replace_variable(&url, variable, maybe_value);
|
||||
}
|
||||
for (key, value) in match_result.params.iter() {
|
||||
if let StringOrNumber::String(name) = key {
|
||||
let maybe_key = tokens.iter().find_map(|t| match t {
|
||||
Token::Key(k) if k.name == *key => Some(k),
|
||||
_ => None,
|
||||
});
|
||||
url_str =
|
||||
url_str.replace(&format!("${{{}}}", name), &value.to_string(maybe_key));
|
||||
url_str = url_str.replace(
|
||||
url = url.replace(&format!("${{{}}}", name), &value.to_string(maybe_key));
|
||||
url = url.replace(
|
||||
&format!("${{{{{}}}}}", name),
|
||||
&percent_encoding::percent_encode(
|
||||
value.to_string(maybe_key).as_bytes(),
|
||||
|
@ -161,7 +200,20 @@ fn get_completion_endpoint(
|
|||
);
|
||||
}
|
||||
}
|
||||
resolve_url(&url_str).map_err(|err| err.into())
|
||||
if !has_value {
|
||||
url = replace_variable(&url, variable, None);
|
||||
}
|
||||
parse_url_with_base(&url, base)
|
||||
}
|
||||
|
||||
/// Based on the preselect response from the registry, determine if this item
|
||||
/// should be preselected or not.
|
||||
fn get_preselect(item: String, preselect: Option<String>) -> Option<bool> {
|
||||
if Some(item) == preselect {
|
||||
Some(true)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_replacement_variables<S: AsRef<str>>(s: S) -> Vec<String> {
|
||||
|
@ -171,11 +223,44 @@ fn parse_replacement_variables<S: AsRef<str>>(s: S) -> Vec<String> {
|
|||
.collect()
|
||||
}
|
||||
|
||||
/// Attempt to parse a URL along with a base, where the base will be used if the
|
||||
/// URL requires one.
|
||||
fn parse_url_with_base(
|
||||
url: &str,
|
||||
base: &ModuleSpecifier,
|
||||
) -> Result<ModuleSpecifier, AnyError> {
|
||||
match Url::parse(url) {
|
||||
Ok(url) => Ok(url),
|
||||
Err(ParseError::RelativeUrlWithoutBase) => {
|
||||
base.join(url).map_err(|err| err.into())
|
||||
}
|
||||
Err(err) => Err(err.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Replaces a variable in a templated URL string with the supplied value or
|
||||
/// "blank" it out if there is no value supplied.
|
||||
fn replace_variable(
|
||||
url: &str,
|
||||
variable: &Key,
|
||||
maybe_value: Option<&str>,
|
||||
) -> String {
|
||||
let url_str = url.to_string();
|
||||
let value = maybe_value.unwrap_or("");
|
||||
if let StringOrNumber::String(name) = &variable.name {
|
||||
url_str
|
||||
.replace(&format!("${{{}}}", name), value)
|
||||
.replace(&format! {"${{{{{}}}}}", name}, value)
|
||||
} else {
|
||||
url_str
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate a registry configuration JSON structure.
|
||||
fn validate_config(config: &RegistryConfigurationJson) -> Result<(), AnyError> {
|
||||
if config.version != 1 {
|
||||
if config.version < 1 || config.version > 2 {
|
||||
return Err(anyhow!(
|
||||
"Invalid registry configuration. Expected version 1 got {}.",
|
||||
"Invalid registry configuration. Expected version 1 or 2 got {}.",
|
||||
config.version
|
||||
));
|
||||
}
|
||||
|
@ -212,13 +297,13 @@ fn validate_config(config: &RegistryConfigurationJson) -> Result<(), AnyError> {
|
|||
let replacement_variables = parse_replacement_variables(&variable.url);
|
||||
let limited_keys = key_names.get(0..key_index).unwrap();
|
||||
for v in replacement_variables {
|
||||
if variable.key == v {
|
||||
if variable.key == v && config.version == 1 {
|
||||
return Err(anyhow!("Invalid registry configuration. Url \"{}\" (for variable \"{}\" in registry with schema \"{}\") uses variable \"{}\", which is not allowed because that would be a self reference.", variable.url, variable.key, registry.schema, v));
|
||||
}
|
||||
|
||||
let key_index = limited_keys.iter().position(|key| key == &v);
|
||||
|
||||
if key_index.is_none() {
|
||||
if key_index.is_none() && variable.key != v {
|
||||
return Err(anyhow!("Invalid registry configuration. Url \"{}\" (for variable \"{}\" in registry with schema \"{}\") uses variable \"{}\", which is not allowed because the schema defines \"{}\" to the right of \"{}\".", variable.url, variable.key, registry.schema, v, v, variable.key));
|
||||
}
|
||||
}
|
||||
|
@ -232,6 +317,9 @@ fn validate_config(config: &RegistryConfigurationJson) -> Result<(), AnyError> {
|
|||
pub(crate) struct RegistryConfigurationVariable {
|
||||
/// The name of the variable.
|
||||
key: String,
|
||||
/// An optional URL/API endpoint that can provide optional documentation for a
|
||||
/// completion item when requested by the language server.
|
||||
documentation: Option<String>,
|
||||
/// The URL with variable substitutions of the endpoint that will provide
|
||||
/// completions for the variable.
|
||||
url: String,
|
||||
|
@ -255,6 +343,16 @@ impl RegistryConfiguration {
|
|||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn get_documentation_url_for_key(&self, key: &Key) -> Option<&str> {
|
||||
self.variables.iter().find_map(|v| {
|
||||
if key.name == StringOrNumber::String(v.key.clone()) {
|
||||
v.documentation.as_deref()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A structure that represents the configuration of an origin and its module
|
||||
|
@ -265,6 +363,22 @@ struct RegistryConfigurationJson {
|
|||
registries: Vec<RegistryConfiguration>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct VariableItemsList {
|
||||
pub items: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub is_incomplete: bool,
|
||||
pub preselect: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum VariableItems {
|
||||
Simple(Vec<String>),
|
||||
List(VariableItemsList),
|
||||
}
|
||||
|
||||
/// A structure which holds the information about currently configured module
|
||||
/// registries and can provide completion information for URLs that match
|
||||
/// one of the enabled registries.
|
||||
|
@ -444,7 +558,7 @@ impl ModuleRegistry {
|
|||
offset: usize,
|
||||
range: &lsp::Range,
|
||||
specifier_exists: impl Fn(&ModuleSpecifier) -> bool,
|
||||
) -> Option<Vec<lsp::CompletionItem>> {
|
||||
) -> Option<lsp::CompletionList> {
|
||||
if let Ok(specifier) = Url::parse(current_specifier) {
|
||||
let origin = base_url(&specifier);
|
||||
let origin_len = origin.chars().count();
|
||||
|
@ -453,6 +567,7 @@ impl ModuleRegistry {
|
|||
let path = &specifier[Position::BeforePath..];
|
||||
let path_offset = offset - origin_len;
|
||||
let mut completions = HashMap::<String, lsp::CompletionItem>::new();
|
||||
let mut is_incomplete = false;
|
||||
let mut did_match = false;
|
||||
for registry in registries {
|
||||
let tokens = parse(®istry.schema, None)
|
||||
|
@ -501,11 +616,26 @@ impl ModuleRegistry {
|
|||
let maybe_url = registry.get_url_for_key(&key);
|
||||
if let Some(url) = maybe_url {
|
||||
if let Some(items) = self
|
||||
.get_variable_items(url, &tokens, &match_result)
|
||||
.get_variable_items(
|
||||
&key,
|
||||
url,
|
||||
&specifier,
|
||||
&tokens,
|
||||
&match_result,
|
||||
)
|
||||
.await
|
||||
{
|
||||
let compiler = Compiler::new(&tokens[..=index], None);
|
||||
let base = Url::parse(&origin).ok()?;
|
||||
let (items, preselect, incomplete) = match items {
|
||||
VariableItems::List(list) => {
|
||||
(list.items, list.preselect, list.is_incomplete)
|
||||
}
|
||||
VariableItems::Simple(items) => (items, None, false),
|
||||
};
|
||||
if incomplete {
|
||||
is_incomplete = true;
|
||||
}
|
||||
for (idx, item) in items.into_iter().enumerate() {
|
||||
let label = if let Some(p) = &prefix {
|
||||
format!("{}{}", p, item)
|
||||
|
@ -546,6 +676,10 @@ impl ModuleRegistry {
|
|||
let detail = Some(format!("({})", key.name));
|
||||
let filter_text = Some(full_text.to_string());
|
||||
let sort_text = Some(format!("{:0>10}", idx + 1));
|
||||
let preselect =
|
||||
get_preselect(item.clone(), preselect.clone());
|
||||
let data =
|
||||
get_data(registry, &specifier, &key, &item);
|
||||
completions.insert(
|
||||
item,
|
||||
lsp::CompletionItem {
|
||||
|
@ -556,6 +690,8 @@ impl ModuleRegistry {
|
|||
filter_text,
|
||||
text_edit,
|
||||
command,
|
||||
preselect,
|
||||
data,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
|
@ -595,6 +731,7 @@ impl ModuleRegistry {
|
|||
filter_text,
|
||||
sort_text: Some("1".to_string()),
|
||||
text_edit,
|
||||
preselect: Some(true),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
|
@ -609,6 +746,17 @@ impl ModuleRegistry {
|
|||
if let Some(url) = maybe_url {
|
||||
if let Some(items) = self.get_items(url).await {
|
||||
let base = Url::parse(&origin).ok()?;
|
||||
let (items, preselect, incomplete) = match items {
|
||||
VariableItems::List(list) => {
|
||||
(list.items, list.preselect, list.is_incomplete)
|
||||
}
|
||||
VariableItems::Simple(items) => {
|
||||
(items, None, false)
|
||||
}
|
||||
};
|
||||
if (incomplete) {
|
||||
is_incomplete = true;
|
||||
}
|
||||
for (idx, item) in items.into_iter().enumerate() {
|
||||
let path = format!("{}{}", prefix, item);
|
||||
let kind = Some(lsp::CompletionItemKind::FOLDER);
|
||||
|
@ -634,6 +782,9 @@ impl ModuleRegistry {
|
|||
let detail = Some(format!("({})", k.name));
|
||||
let filter_text = Some(full_text.to_string());
|
||||
let sort_text = Some(format!("{:0>10}", idx + 1));
|
||||
let preselect =
|
||||
get_preselect(item.clone(), preselect.clone());
|
||||
let data = get_data(registry, &specifier, k, &path);
|
||||
completions.insert(
|
||||
item.clone(),
|
||||
lsp::CompletionItem {
|
||||
|
@ -644,6 +795,8 @@ impl ModuleRegistry {
|
|||
filter_text,
|
||||
text_edit,
|
||||
command,
|
||||
preselect,
|
||||
data,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
|
@ -663,7 +816,10 @@ impl ModuleRegistry {
|
|||
return if completions.is_empty() && !did_match {
|
||||
None
|
||||
} else {
|
||||
Some(completions.into_iter().map(|(_, i)| i).collect())
|
||||
Some(lsp::CompletionList {
|
||||
items: completions.into_iter().map(|(_, i)| i).collect(),
|
||||
is_incomplete,
|
||||
})
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -672,11 +828,24 @@ impl ModuleRegistry {
|
|||
self.get_origin_completions(current_specifier, range)
|
||||
}
|
||||
|
||||
pub async fn get_documentation(
|
||||
&self,
|
||||
url: &str,
|
||||
) -> Option<lsp::Documentation> {
|
||||
let specifier = Url::parse(url).ok()?;
|
||||
let file = self
|
||||
.file_fetcher
|
||||
.fetch(&specifier, &mut Permissions::allow_all())
|
||||
.await
|
||||
.ok()?;
|
||||
serde_json::from_str(&file.source).ok()
|
||||
}
|
||||
|
||||
pub fn get_origin_completions(
|
||||
&self,
|
||||
current_specifier: &str,
|
||||
range: &lsp::Range,
|
||||
) -> Option<Vec<lsp::CompletionItem>> {
|
||||
) -> Option<lsp::CompletionList> {
|
||||
let items = self
|
||||
.origins
|
||||
.keys()
|
||||
|
@ -704,13 +873,16 @@ impl ModuleRegistry {
|
|||
})
|
||||
.collect::<Vec<lsp::CompletionItem>>();
|
||||
if !items.is_empty() {
|
||||
Some(items)
|
||||
Some(lsp::CompletionList {
|
||||
items,
|
||||
is_incomplete: false,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_items(&self, url: &str) -> Option<Vec<String>> {
|
||||
async fn get_items(&self, url: &str) -> Option<VariableItems> {
|
||||
let specifier = ModuleSpecifier::parse(url).ok()?;
|
||||
let file = self
|
||||
.file_fetcher
|
||||
|
@ -723,7 +895,7 @@ impl ModuleRegistry {
|
|||
);
|
||||
})
|
||||
.ok()?;
|
||||
let items: Vec<String> = serde_json::from_str(&file.source)
|
||||
let items: VariableItems = serde_json::from_str(&file.source)
|
||||
.map_err(|err| {
|
||||
error!(
|
||||
"Error parsing response from endpoint \"{}\". {}",
|
||||
|
@ -736,15 +908,18 @@ impl ModuleRegistry {
|
|||
|
||||
async fn get_variable_items(
|
||||
&self,
|
||||
variable: &Key,
|
||||
url: &str,
|
||||
base: &Url,
|
||||
tokens: &[Token],
|
||||
match_result: &MatchResult,
|
||||
) -> Option<Vec<String>> {
|
||||
let specifier = get_completion_endpoint(url, tokens, match_result)
|
||||
.map_err(|err| {
|
||||
error!("Internal error mapping endpoint \"{}\". {}", url, err);
|
||||
})
|
||||
.ok()?;
|
||||
) -> Option<VariableItems> {
|
||||
let specifier =
|
||||
get_endpoint_with_match(variable, url, base, tokens, match_result, None)
|
||||
.map_err(|err| {
|
||||
error!("Internal error mapping endpoint \"{}\". {}", url, err);
|
||||
})
|
||||
.ok()?;
|
||||
let file = self
|
||||
.file_fetcher
|
||||
.fetch(&specifier, &mut Permissions::allow_all())
|
||||
|
@ -756,7 +931,7 @@ impl ModuleRegistry {
|
|||
);
|
||||
})
|
||||
.ok()?;
|
||||
let items: Vec<String> = serde_json::from_str(&file.source)
|
||||
let items: VariableItems = serde_json::from_str(&file.source)
|
||||
.map_err(|err| {
|
||||
error!(
|
||||
"Error parsing response from endpoint \"{}\". {}",
|
||||
|
@ -776,7 +951,7 @@ mod tests {
|
|||
#[test]
|
||||
fn test_validate_registry_configuration() {
|
||||
assert!(validate_config(&RegistryConfigurationJson {
|
||||
version: 2,
|
||||
version: 3,
|
||||
registries: vec![],
|
||||
})
|
||||
.is_err());
|
||||
|
@ -788,10 +963,12 @@ mod tests {
|
|||
variables: vec![
|
||||
RegistryConfigurationVariable {
|
||||
key: "module".to_string(),
|
||||
documentation: None,
|
||||
url: "https://api.deno.land/modules?short".to_string(),
|
||||
},
|
||||
RegistryConfigurationVariable {
|
||||
key: "version".to_string(),
|
||||
documentation: None,
|
||||
url: "https://deno.land/_vsc1/module/${module}".to_string(),
|
||||
},
|
||||
],
|
||||
|
@ -806,14 +983,17 @@ mod tests {
|
|||
variables: vec![
|
||||
RegistryConfigurationVariable {
|
||||
key: "module".to_string(),
|
||||
documentation: None,
|
||||
url: "https://api.deno.land/modules?short".to_string(),
|
||||
},
|
||||
RegistryConfigurationVariable {
|
||||
key: "version".to_string(),
|
||||
documentation: None,
|
||||
url: "https://deno.land/_vsc1/module/${module}/${path}".to_string(),
|
||||
},
|
||||
RegistryConfigurationVariable {
|
||||
key: "path".to_string(),
|
||||
documentation: None,
|
||||
url: "https://deno.land/_vsc1/module/${module}/v/${{version}}"
|
||||
.to_string(),
|
||||
},
|
||||
|
@ -829,15 +1009,18 @@ mod tests {
|
|||
variables: vec![
|
||||
RegistryConfigurationVariable {
|
||||
key: "module".to_string(),
|
||||
documentation: None,
|
||||
url: "https://api.deno.land/modules?short".to_string(),
|
||||
},
|
||||
RegistryConfigurationVariable {
|
||||
key: "version".to_string(),
|
||||
documentation: None,
|
||||
url: "https://deno.land/_vsc1/module/${module}/v/${{version}}"
|
||||
.to_string(),
|
||||
},
|
||||
RegistryConfigurationVariable {
|
||||
key: "path".to_string(),
|
||||
documentation: None,
|
||||
url: "https://deno.land/_vsc1/module/${module}/v/${{version}}"
|
||||
.to_string(),
|
||||
},
|
||||
|
@ -853,21 +1036,66 @@ mod tests {
|
|||
variables: vec![
|
||||
RegistryConfigurationVariable {
|
||||
key: "module".to_string(),
|
||||
documentation: None,
|
||||
url: "https://api.deno.land/modules?short".to_string(),
|
||||
},
|
||||
RegistryConfigurationVariable {
|
||||
key: "version".to_string(),
|
||||
documentation: None,
|
||||
url: "https://deno.land/_vsc1/module/${module}".to_string(),
|
||||
},
|
||||
RegistryConfigurationVariable {
|
||||
key: "path".to_string(),
|
||||
documentation: None,
|
||||
url: "https://deno.land/_vsc1/module/${module}/v/${{version}}"
|
||||
.to_string(),
|
||||
},
|
||||
],
|
||||
}],
|
||||
};
|
||||
validate_config(&cfg).unwrap();
|
||||
assert!(validate_config(&cfg).is_ok());
|
||||
|
||||
let cfg: RegistryConfigurationJson = serde_json::from_value(json!({
|
||||
"version": 2,
|
||||
"registries": [
|
||||
{
|
||||
"schema": "/x/:module([a-z0-9_]+)@:version?/:path",
|
||||
"variables": [
|
||||
{
|
||||
"key": "module",
|
||||
"documentation": "/api/details/mods/${module}",
|
||||
"url": "/api/mods/${module}"
|
||||
},
|
||||
{
|
||||
"key": "version",
|
||||
"documentation": "/api/details/mods/${module}/v/${{version}}",
|
||||
"url": "/api/mods/${module}/v/${{version}}"
|
||||
},
|
||||
{
|
||||
"key": "path",
|
||||
"documentation": "/api/details/mods/${module}/v/${{version}}/p/${path}",
|
||||
"url": "/api/mods/${module}/v/${{version}}/p/${path}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"schema": "/x/:module([a-z0-9_]+)/:path",
|
||||
"variables": [
|
||||
{
|
||||
"key": "module",
|
||||
"documentation": "/api/details/mods/${module}",
|
||||
"url": "/api/mods/${module}"
|
||||
},
|
||||
{
|
||||
"key": "path",
|
||||
"documentation": "/api/details/mods/${module}/v/latest/p/${path}",
|
||||
"url": "/api/mods/${module}/v/latest/p/${path}"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})).unwrap();
|
||||
assert!(validate_config(&cfg).is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
@ -894,7 +1122,7 @@ mod tests {
|
|||
.get_completions("h", 1, &range, |_| false)
|
||||
.await;
|
||||
assert!(completions.is_some());
|
||||
let completions = completions.unwrap();
|
||||
let completions = completions.unwrap().items;
|
||||
assert_eq!(completions.len(), 1);
|
||||
assert_eq!(completions[0].label, "http://localhost:4545");
|
||||
assert_eq!(
|
||||
|
@ -918,7 +1146,7 @@ mod tests {
|
|||
.get_completions("http://localhost", 16, &range, |_| false)
|
||||
.await;
|
||||
assert!(completions.is_some());
|
||||
let completions = completions.unwrap();
|
||||
let completions = completions.unwrap().items;
|
||||
assert_eq!(completions.len(), 1);
|
||||
assert_eq!(completions[0].label, "http://localhost:4545");
|
||||
assert_eq!(
|
||||
|
@ -954,7 +1182,7 @@ mod tests {
|
|||
.get_completions("http://localhost:4545", 21, &range, |_| false)
|
||||
.await;
|
||||
assert!(completions.is_some());
|
||||
let completions = completions.unwrap();
|
||||
let completions = completions.unwrap().items;
|
||||
assert_eq!(completions.len(), 1);
|
||||
assert_eq!(completions[0].label, "/x");
|
||||
assert_eq!(
|
||||
|
@ -978,7 +1206,7 @@ mod tests {
|
|||
.get_completions("http://localhost:4545/", 22, &range, |_| false)
|
||||
.await;
|
||||
assert!(completions.is_some());
|
||||
let completions = completions.unwrap();
|
||||
let completions = completions.unwrap().items;
|
||||
assert_eq!(completions.len(), 1);
|
||||
assert_eq!(completions[0].label, "/x");
|
||||
assert_eq!(
|
||||
|
@ -1003,9 +1231,52 @@ mod tests {
|
|||
.await;
|
||||
assert!(completions.is_some());
|
||||
let completions = completions.unwrap();
|
||||
assert_eq!(completions.len(), 2);
|
||||
assert!(completions[0].label == *"a" || completions[0].label == *"b");
|
||||
assert!(completions[1].label == *"a" || completions[1].label == *"b");
|
||||
assert_eq!(completions.items.len(), 2);
|
||||
assert!(completions.is_incomplete);
|
||||
assert!(
|
||||
completions.items[0].label == *"a" || completions.items[0].label == *"b"
|
||||
);
|
||||
assert!(
|
||||
completions.items[1].label == *"a" || completions.items[1].label == *"b"
|
||||
);
|
||||
|
||||
// testing for incremental searching for a module
|
||||
let range = lsp::Range {
|
||||
start: lsp::Position {
|
||||
line: 0,
|
||||
character: 20,
|
||||
},
|
||||
end: lsp::Position {
|
||||
line: 0,
|
||||
character: 45,
|
||||
},
|
||||
};
|
||||
let completions = module_registry
|
||||
.get_completions("http://localhost:4545/x/a", 25, &range, |_| false)
|
||||
.await;
|
||||
assert!(completions.is_some());
|
||||
let completions = completions.unwrap();
|
||||
assert_eq!(completions.items.len(), 4);
|
||||
assert!(!completions.is_incomplete);
|
||||
assert_eq!(
|
||||
completions.items[0].data,
|
||||
Some(json!({
|
||||
"documentation": format!("http://localhost:4545/lsp/registries/doc_{}.json", completions.items[0].label),
|
||||
}))
|
||||
);
|
||||
|
||||
// testing getting the documentation
|
||||
let documentation = module_registry
|
||||
.get_documentation("http://localhost:4545/lsp/registries/doc_a.json")
|
||||
.await;
|
||||
assert_eq!(
|
||||
documentation,
|
||||
Some(lsp::Documentation::MarkupContent(lsp::MarkupContent {
|
||||
kind: lsp::MarkupKind::Markdown,
|
||||
value: "**a**".to_string(),
|
||||
}))
|
||||
);
|
||||
|
||||
let range = lsp::Range {
|
||||
start: lsp::Position {
|
||||
line: 0,
|
||||
|
@ -1020,7 +1291,7 @@ mod tests {
|
|||
.get_completions("http://localhost:4545/x/a@", 26, &range, |_| false)
|
||||
.await;
|
||||
assert!(completions.is_some());
|
||||
let completions = completions.unwrap();
|
||||
let completions = completions.unwrap().items;
|
||||
assert_eq!(completions.len(), 3);
|
||||
let range = lsp::Range {
|
||||
start: lsp::Position {
|
||||
|
@ -1038,7 +1309,7 @@ mod tests {
|
|||
})
|
||||
.await;
|
||||
assert!(completions.is_some());
|
||||
let completions = completions.unwrap();
|
||||
let completions = completions.unwrap().items;
|
||||
assert_eq!(completions.len(), 2);
|
||||
assert_eq!(completions[0].detail, Some("(path)".to_string()));
|
||||
assert_eq!(completions[0].kind, Some(lsp::CompletionItemKind::FILE));
|
||||
|
@ -1072,7 +1343,7 @@ mod tests {
|
|||
.get_completions("http://localhost:4545/", 22, &range, |_| false)
|
||||
.await;
|
||||
assert!(completions.is_some());
|
||||
let completions = completions.unwrap();
|
||||
let completions = completions.unwrap().items;
|
||||
assert_eq!(completions.len(), 3);
|
||||
for completion in completions {
|
||||
assert!(completion.text_edit.is_some());
|
||||
|
@ -1101,7 +1372,7 @@ mod tests {
|
|||
.get_completions("http://localhost:4545/cde@", 26, &range, |_| false)
|
||||
.await;
|
||||
assert!(completions.is_some());
|
||||
let completions = completions.unwrap();
|
||||
let completions = completions.unwrap().items;
|
||||
assert_eq!(completions.len(), 2);
|
||||
for completion in completions {
|
||||
assert!(completion.text_edit.is_some());
|
||||
|
@ -1141,7 +1412,7 @@ mod tests {
|
|||
.get_completions("http://localhost:4545/", 22, &range, |_| false)
|
||||
.await;
|
||||
assert!(completions.is_some());
|
||||
let completions = completions.unwrap();
|
||||
let completions = completions.unwrap().items;
|
||||
assert_eq!(completions.len(), 3);
|
||||
for completion in completions {
|
||||
assert!(completion.text_edit.is_some());
|
||||
|
|
84
cli/schemas/registry-completions.v1.json
Normal file
84
cli/schemas/registry-completions.v1.json
Normal file
|
@ -0,0 +1,84 @@
|
|||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"$id": "https://deno.land/x/deno/cli/schemas/registry-completions.v1.json",
|
||||
"title": "Deno Registry Completion Schema",
|
||||
"description": "A definition of meta data that allows a Deno language server form auto completion suggestions for modules within a module registry.",
|
||||
"required": [
|
||||
"version",
|
||||
"registries"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"version": {
|
||||
"description": "The version of the schema document.",
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 1,
|
||||
"examples": [
|
||||
1
|
||||
]
|
||||
},
|
||||
"registries": {
|
||||
"default": [],
|
||||
"description": "The registries that exist for this origin.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/registry"
|
||||
}
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"registry": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schema",
|
||||
"variables"
|
||||
],
|
||||
"properties": {
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"description": "The Express-like path matching string, where a specifier in the editor will be matched against, and the variables interpolated to provide a completion suggestion.",
|
||||
"examples": [
|
||||
"/:package([a-z0-9_]*)/:path*",
|
||||
"/:package([a-z0-9_]*)@:version?/:path*"
|
||||
]
|
||||
},
|
||||
"variables": {
|
||||
"default": [],
|
||||
"description": "The variables that are contained in the schema string.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/variable"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"variable": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"key",
|
||||
"url"
|
||||
],
|
||||
"properties": {
|
||||
"key": {
|
||||
"type": "string",
|
||||
"description": "The variable key name in the schema, which will be used to build the completion suggestions.",
|
||||
"examples": [
|
||||
"package",
|
||||
"version",
|
||||
"path"
|
||||
]
|
||||
},
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": "The \"endpoint\" to call to provide values to complete the specifier. This endpoint should return an array of strings. Parsed values can be substituted using ${key} syntax.",
|
||||
"examples": [
|
||||
"https://example.com/api/packages",
|
||||
"https://example.com/api/packages/{package}",
|
||||
"https://example.com/api/packages/{package}/${{version}}"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
93
cli/schemas/registry-completions.v2.json
Normal file
93
cli/schemas/registry-completions.v2.json
Normal file
|
@ -0,0 +1,93 @@
|
|||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"$id": "https://deno.land/x/deno/cli/schemas/registry-completions.v2.json",
|
||||
"title": "Deno Registry Completion Schema",
|
||||
"description": "A definition of meta data that allows a Deno language server form auto completion suggestions for modules within a module registry.",
|
||||
"required": [
|
||||
"version",
|
||||
"registries"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"version": {
|
||||
"description": "The version of the schema document.",
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 2,
|
||||
"examples": [
|
||||
2
|
||||
]
|
||||
},
|
||||
"registries": {
|
||||
"default": [],
|
||||
"description": "The registries that exist for this origin.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/registry"
|
||||
}
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"registry": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schema",
|
||||
"variables"
|
||||
],
|
||||
"properties": {
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"description": "The Express-like path matching string, where a specifier in the editor will be matched against, and the variables interpolated to provide a completion suggestion.",
|
||||
"examples": [
|
||||
"/:package([a-z0-9_]*)/:path*",
|
||||
"/:package([a-z0-9_]*)@:version?/:path*"
|
||||
]
|
||||
},
|
||||
"variables": {
|
||||
"default": [],
|
||||
"description": "The variables that are contained in the schema string.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/variable"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"variable": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"key",
|
||||
"url"
|
||||
],
|
||||
"properties": {
|
||||
"key": {
|
||||
"type": "string",
|
||||
"description": "The variable key name in the schema, which will be used to build the completion suggestions.",
|
||||
"examples": [
|
||||
"package",
|
||||
"version",
|
||||
"path"
|
||||
]
|
||||
},
|
||||
"documentation": {
|
||||
"type": "string",
|
||||
"description": "An optional \"endpoint\" to call to provide documentation for specified variable, which can be displayed to the client in the response. This can provide description information about the item. The value should contain the variable name in the template. Baseless URLs can be supplied and the host for the configuration file will be used as the base.",
|
||||
"examples": [
|
||||
"https://example.com/api/documentation/packages/${package}/${{version}}/${path}",
|
||||
"/api/documentation/packages/${package}/${{version}}/${path}"
|
||||
]
|
||||
},
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": "The \"endpoint\" to call to provide values to complete the specifier. This endpoint should return an array of strings. Parsed values can be substituted using ${key} syntax. In order to support incremental completions, the value should contain the variable name in the template. Baseless URLs can be supplied and the host for the configuration file will be used as the base.",
|
||||
"examples": [
|
||||
"https://example.com/api/packages",
|
||||
"https://example.com/api/packages/${package}",
|
||||
"https://example.com/api/packages/${package}/${{version}}",
|
||||
"/api/packages/${package}/${{version}}/${path}"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,20 +1,21 @@
|
|||
{
|
||||
"version": 1,
|
||||
"version": 2,
|
||||
"registries": [
|
||||
{
|
||||
"schema": "/x/:module([a-z0-9_]*)@:version?/:path*",
|
||||
"variables": [
|
||||
{
|
||||
"key": "module",
|
||||
"url": "http://localhost:4545/lsp/registries/modules.json"
|
||||
"documentation": "/lsp/registries/doc_${module}.json",
|
||||
"url": "/lsp/registries/modules_${module}.json"
|
||||
},
|
||||
{
|
||||
"key": "version",
|
||||
"url": "http://localhost:4545/lsp/registries/${module}_versions.json"
|
||||
"url": "/lsp/registries/${module}_versions.json"
|
||||
},
|
||||
{
|
||||
"key": "path",
|
||||
"url": "http://localhost:4545/lsp/registries/${module}_${{version}}.json"
|
||||
"url": "/lsp/registries/${module}_${{version}}.json"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
@ -23,11 +24,12 @@
|
|||
"variables": [
|
||||
{
|
||||
"key": "module",
|
||||
"url": "http://localhost:4545/lsp/registries/modules.json"
|
||||
"documentation": "/lsp/registries/doc_${module}.json",
|
||||
"url": "/lsp/registries/modules_${module}.json"
|
||||
},
|
||||
{
|
||||
"key": "path",
|
||||
"url": "http://localhost:4545/lsp/registries/${module}_latest.json"
|
||||
"url": "/lsp/registries/${module}_latest.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
4
cli/tests/testdata/lsp/registries/doc_a.json
vendored
Normal file
4
cli/tests/testdata/lsp/registries/doc_a.json
vendored
Normal file
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"kind": "markdown",
|
||||
"value": "**a**"
|
||||
}
|
|
@ -1,4 +0,0 @@
|
|||
[
|
||||
"a",
|
||||
"b"
|
||||
]
|
8
cli/tests/testdata/lsp/registries/modules_.json
vendored
Normal file
8
cli/tests/testdata/lsp/registries/modules_.json
vendored
Normal file
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"items": [
|
||||
"a",
|
||||
"b"
|
||||
],
|
||||
"isIncomplete": true,
|
||||
"preselect": "a"
|
||||
}
|
10
cli/tests/testdata/lsp/registries/modules_a.json
vendored
Normal file
10
cli/tests/testdata/lsp/registries/modules_a.json
vendored
Normal file
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"items": [
|
||||
"a",
|
||||
"aa",
|
||||
"ab",
|
||||
"aba"
|
||||
],
|
||||
"isIncomplete": false,
|
||||
"preselect": "a"
|
||||
}
|
Loading…
Reference in a new issue