diff --git a/cli/lsp/completions.rs b/cli/lsp/completions.rs index 601373145305b0..62115985a0638e 100644 --- a/cli/lsp/completions.rs +++ b/cli/lsp/completions.rs @@ -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 docs: Option, #[serde(skip_serializing_if = "Option::is_none")] pub tsc: Option, } @@ -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 = 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 diff --git a/cli/lsp/language_server.rs b/cli/lsp/language_server.rs index 94cc98f603ba66..2e1cecac0bb331 100644 --- a/cli/lsp/language_server.rs +++ b/cli/lsp/language_server.rs @@ -1773,6 +1773,15 @@ impl Inner { ); params } + } else if let Some(docs_url) = data.docs { + CompletionItem { + documentation: self + .module_registries + .get_documentation(&docs_url) + .await, + data: None, + ..params + } } else { params } diff --git a/cli/lsp/registries.rs b/cli/lsp/registries.rs index fda8d5205828ed..26c04e18f366a3 100644 --- a/cli/lsp/registries.rs +++ b/cli/lsp/registries.rs @@ -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 { + let url = registry.get_details_url_for_key(variable)?; + get_endpoint(url, base, variable, Some(value)) + .ok() + .map(|specifier| json!({ "registry": 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 { + 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 { - 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) -> Option { + if Some(item) == preselect { + Some(true) + } else { + None + } } fn parse_replacement_variables>(s: S) -> Vec { @@ -171,11 +223,44 @@ fn parse_replacement_variables>(s: S) -> Vec { .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 { + 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 details for a + /// completion item when requested by the language server. + details: Option, /// 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_details_url_for_key(&self, key: &Key) -> Option<&str> { + self.variables.iter().find_map(|v| { + if key.name == StringOrNumber::String(v.key.clone()) { + v.details.as_deref() + } else { + None + } + }) + } } /// A structure that represents the configuration of an origin and its module @@ -265,6 +363,22 @@ struct RegistryConfigurationJson { registries: Vec, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct VariableItemsList { + pub items: Vec, + #[serde(default)] + pub is_incomplete: bool, + pub preselect: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum VariableItems { + Simple(Vec), + 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. @@ -439,7 +553,7 @@ impl ModuleRegistry { offset: usize, range: &lsp::Range, specifier_exists: impl Fn(&ModuleSpecifier) -> bool, - ) -> Option> { + ) -> Option { if let Ok(specifier) = Url::parse(current_specifier) { let origin = base_url(&specifier); let origin_len = origin.chars().count(); @@ -448,6 +562,7 @@ impl ModuleRegistry { let path = &specifier[Position::BeforePath..]; let path_offset = offset - origin_len; let mut completions = HashMap::::new(); + let mut is_incomplete = false; let mut did_match = false; for registry in registries { let tokens = parse(®istry.schema, None) @@ -496,11 +611,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) @@ -541,6 +671,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 { @@ -551,6 +685,8 @@ impl ModuleRegistry { filter_text, text_edit, command, + preselect, + data, ..Default::default() }, ); @@ -590,6 +726,7 @@ impl ModuleRegistry { filter_text, sort_text: Some("1".to_string()), text_edit, + preselect: Some(true), ..Default::default() }, ); @@ -604,6 +741,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); @@ -629,6 +777,10 @@ 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 { @@ -639,6 +791,8 @@ impl ModuleRegistry { filter_text, text_edit, command, + preselect, + data, ..Default::default() }, ); @@ -658,7 +812,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, + }) }; } } @@ -667,11 +824,24 @@ impl ModuleRegistry { self.get_origin_completions(current_specifier, range) } + pub async fn get_documentation( + &self, + url: &str, + ) -> Option { + 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> { + ) -> Option { let items = self .origins .keys() @@ -699,13 +869,16 @@ impl ModuleRegistry { }) .collect::>(); if !items.is_empty() { - Some(items) + Some(lsp::CompletionList { + items, + is_incomplete: false, + }) } else { None } } - async fn get_items(&self, url: &str) -> Option> { + async fn get_items(&self, url: &str) -> Option { let specifier = ModuleSpecifier::parse(url).ok()?; let file = self .file_fetcher @@ -718,7 +891,7 @@ impl ModuleRegistry { ); }) .ok()?; - let items: Vec = serde_json::from_str(&file.source) + let items: VariableItems = serde_json::from_str(&file.source) .map_err(|err| { error!( "Error parsing response from endpoint \"{}\". {}", @@ -731,15 +904,18 @@ impl ModuleRegistry { async fn get_variable_items( &self, + variable: &Key, url: &str, + base: &Url, tokens: &[Token], match_result: &MatchResult, - ) -> Option> { - let specifier = get_completion_endpoint(url, tokens, match_result) - .map_err(|err| { - error!("Internal error mapping endpoint \"{}\". {}", url, err); - }) - .ok()?; + ) -> Option { + 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()) @@ -751,7 +927,7 @@ impl ModuleRegistry { ); }) .ok()?; - let items: Vec = serde_json::from_str(&file.source) + let items: VariableItems = serde_json::from_str(&file.source) .map_err(|err| { error!( "Error parsing response from endpoint \"{}\". {}", @@ -771,7 +947,7 @@ mod tests { #[test] fn test_validate_registry_configuration() { assert!(validate_config(&RegistryConfigurationJson { - version: 2, + version: 3, registries: vec![], }) .is_err()); @@ -783,10 +959,12 @@ mod tests { variables: vec![ RegistryConfigurationVariable { key: "module".to_string(), + details: None, url: "https://api.deno.land/modules?short".to_string(), }, RegistryConfigurationVariable { key: "version".to_string(), + details: None, url: "https://deno.land/_vsc1/module/${module}".to_string(), }, ], @@ -801,14 +979,17 @@ mod tests { variables: vec![ RegistryConfigurationVariable { key: "module".to_string(), + details: None, url: "https://api.deno.land/modules?short".to_string(), }, RegistryConfigurationVariable { key: "version".to_string(), + details: None, url: "https://deno.land/_vsc1/module/${module}/${path}".to_string(), }, RegistryConfigurationVariable { key: "path".to_string(), + details: None, url: "https://deno.land/_vsc1/module/${module}/v/${{version}}" .to_string(), }, @@ -824,15 +1005,18 @@ mod tests { variables: vec![ RegistryConfigurationVariable { key: "module".to_string(), + details: None, url: "https://api.deno.land/modules?short".to_string(), }, RegistryConfigurationVariable { key: "version".to_string(), + details: None, url: "https://deno.land/_vsc1/module/${module}/v/${{version}}" .to_string(), }, RegistryConfigurationVariable { key: "path".to_string(), + details: None, url: "https://deno.land/_vsc1/module/${module}/v/${{version}}" .to_string(), }, @@ -848,14 +1032,17 @@ mod tests { variables: vec![ RegistryConfigurationVariable { key: "module".to_string(), + details: None, url: "https://api.deno.land/modules?short".to_string(), }, RegistryConfigurationVariable { key: "version".to_string(), + details: None, url: "https://deno.land/_vsc1/module/${module}".to_string(), }, RegistryConfigurationVariable { key: "path".to_string(), + details: None, url: "https://deno.land/_vsc1/module/${module}/v/${{version}}" .to_string(), }, @@ -889,7 +1076,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!( @@ -913,7 +1100,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!( @@ -949,7 +1136,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!( @@ -973,7 +1160,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!( @@ -997,7 +1184,7 @@ mod tests { .get_completions("http://localhost:4545/x/", 24, &range, |_| false) .await; assert!(completions.is_some()); - let completions = completions.unwrap(); + let completions = completions.unwrap().items; assert_eq!(completions.len(), 2); assert!(completions[0].label == *"a" || completions[0].label == *"b"); assert!(completions[1].label == *"a" || completions[1].label == *"b"); @@ -1015,7 +1202,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 { @@ -1033,7 +1220,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)); @@ -1067,7 +1254,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()); @@ -1096,7 +1283,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()); @@ -1136,7 +1323,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()); diff --git a/cli/schemas/registry-completions.v1.json b/cli/schemas/registry-completions.v1.json new file mode 100644 index 00000000000000..6ec12c8f5328bb --- /dev/null +++ b/cli/schemas/registry-completions.v1.json @@ -0,0 +1,82 @@ +{ + "$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": "number", + "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}}" + ] + } + } + } + } +} diff --git a/cli/schemas/registry-completions.v2.json b/cli/schemas/registry-completions.v2.json new file mode 100644 index 00000000000000..bc09a066a87cb5 --- /dev/null +++ b/cli/schemas/registry-completions.v2.json @@ -0,0 +1,96 @@ +{ + "$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": "number", + "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" + ] + }, + "details": { + "type": "string", + "description": "An optional \"endpoint\" to call to provide details for specified variable, which can be displayed to the client in the response. This can provide description information about the item.", + "examples": [ + "https://example.com/api/modules/${module}/${{version}}/details/${path}" + ] + }, + "parts": { + "type": "string", + "description": "An optional \"endpoint\" to call to provide incremental results of a complex variable, delimited by a path separator of \"/\".", + "examples": [ + "https://example.com/api/modules/${module}/${{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}}" + ] + } + } + } + } +}