diff --git a/extensions/json/server/src/jsonCompletion.ts b/extensions/json/server/src/jsonCompletion.ts index a8d524e2516..09b82a7042f 100644 --- a/extensions/json/server/src/jsonCompletion.ts +++ b/extensions/json/server/src/jsonCompletion.ts @@ -11,11 +11,12 @@ import JsonSchema = require('./json-toolbox/jsonSchema'); import nls = require('./utils/nls'); import {IJSONWorkerContribution} from './jsonContributions'; -import {CompletionItem, CompletionItemKind, CompletionList, ITextDocument, TextDocumentPosition, Range, TextEdit} from 'vscode-languageserver'; +import {CompletionItem, CompletionItemKind, CompletionList, ITextDocument, TextDocumentPosition, Range, TextEdit, RemoteConsole} from 'vscode-languageserver'; export interface ISuggestionsCollector { add(suggestion: CompletionItem): void; error(message:string): void; + log(message:string): void; setAsIncomplete(): void; } @@ -23,10 +24,24 @@ export class JSONCompletion { private schemaService: SchemaService.IJSONSchemaService; private contributions: IJSONWorkerContribution[]; + private console: RemoteConsole; - constructor(schemaService: SchemaService.IJSONSchemaService, contributions: IJSONWorkerContribution[] = []) { + constructor(schemaService: SchemaService.IJSONSchemaService, console: RemoteConsole, contributions: IJSONWorkerContribution[] = []) { this.schemaService = schemaService; this.contributions = contributions; + this.console = console; + } + + public doResolve(item: CompletionItem) : Thenable { + for (let i = this.contributions.length - 1; i >= 0; i--) { + if (this.contributions[i].resolveSuggestion) { + let resolver = this.contributions[i].resolveSuggestion(item); + if (resolver) { + return resolver; + } + } + } + return Promise.resolve(item); } public doSuggest(document: ITextDocument, textDocumentPosition: TextDocumentPosition, doc: Parser.JSONDocument): Thenable { @@ -63,7 +78,10 @@ export class JSONCompletion { result.isIncomplete = true; }, error: (message: string) => { - console.log(message); + this.console.error(message); + }, + log: (message: string) => { + this.console.log(message); } }; diff --git a/extensions/json/server/src/jsonContributions.ts b/extensions/json/server/src/jsonContributions.ts index bc518f5de84..b6211fb63cc 100644 --- a/extensions/json/server/src/jsonContributions.ts +++ b/extensions/json/server/src/jsonContributions.ts @@ -7,7 +7,7 @@ import {JSONLocation} from './jsonLocation'; import {ISuggestionsCollector} from './jsonCompletion'; -import {MarkedString} from 'vscode-languageserver'; +import {MarkedString, CompletionItem} from 'vscode-languageserver'; export {ISuggestionsCollector} from './jsonCompletion'; @@ -17,4 +17,5 @@ export interface IJSONWorkerContribution { collectPropertySuggestions(resource: string, location: JSONLocation, currentWord: string, addValue: boolean, isLast:boolean, result: ISuggestionsCollector) : Thenable; collectValueSuggestions(resource: string, location: JSONLocation, propertyKey: string, result: ISuggestionsCollector): Thenable; collectDefaultSuggestions(resource: string, result: ISuggestionsCollector): Thenable; + resolveSuggestion?(item: CompletionItem): Thenable; } \ No newline at end of file diff --git a/extensions/json/server/src/jsonHover.ts b/extensions/json/server/src/jsonHover.ts index 135ef53e8b4..5aa6cf6e9ae 100644 --- a/extensions/json/server/src/jsonHover.ts +++ b/extensions/json/server/src/jsonHover.ts @@ -39,6 +39,24 @@ export class JSONHover { if (!node) { return Promise.resolve(void 0); } + + var createHover = (contents: MarkedString[]) => { + let range = Range.create(document.positionAt(node.start), document.positionAt(node.end)); + let result: Hover = { + contents: contents, + range: range + }; + return result; + }; + + let location = node.getNodeLocation(); + for (let i = this.contributions.length - 1; i >= 0; i--) { + let contribution = this.contributions[i]; + let promise = contribution.getInfoContribution(textDocumentPosition.uri, location); + if (promise) { + return promise.then(htmlContent => createHover(htmlContent)); + } + } return this.schemaService.getSchemaForResource(textDocumentPosition.uri, doc).then((schema) => { if (schema) { @@ -46,33 +64,12 @@ export class JSONHover { doc.validate(schema.schema, matchingSchemas, node.start); let description: string = null; - let contributonId: string = null; matchingSchemas.every((s) => { if (s.node === node && !s.inverted && s.schema) { description = description || s.schema.description; - contributonId = contributonId || s.schema.id; } return true; }); - - var createHover = (contents: MarkedString[]) => { - let range = Range.create(document.positionAt(node.start), document.positionAt(node.end)); - let result: Hover = { - contents: contents, - range: range - }; - return result; - }; - - let location = node.getNodeLocation(); - for (let i = this.contributions.length - 1; i >= 0; i--) { - let contribution = this.contributions[i]; - let promise = contribution.getInfoContribution(textDocumentPosition.uri, location); - if (promise) { - return promise.then(htmlContent => createHover(htmlContent)); - } - } - if (description) { return createHover([description]); } diff --git a/extensions/json/server/src/jsoncontributions/projectJSONContribution.ts b/extensions/json/server/src/jsoncontributions/projectJSONContribution.ts index ba33caa38a7..a3c36ee073b 100644 --- a/extensions/json/server/src/jsoncontributions/projectJSONContribution.ts +++ b/extensions/json/server/src/jsoncontributions/projectJSONContribution.ts @@ -4,19 +4,34 @@ *--------------------------------------------------------------------------------------------*/ 'use strict'; -import {MarkedString, CompletionItemKind} from 'vscode-languageserver'; +import {MarkedString, CompletionItemKind, CompletionItem} from 'vscode-languageserver'; import Strings = require('../utils/strings'); import nls = require('../utils/nls'); +import {IXHRResponse, getErrorStatusDescription} from '../utils/httpRequest'; import {IJSONWorkerContribution, ISuggestionsCollector} from '../jsonContributions'; import {IRequestService} from '../jsonSchemaService'; import {JSONLocation} from '../jsonLocation'; -let LIMIT = 40; +const FEED_INDEX_URL = 'https://api.nuget.org/v3/index.json'; +const LIMIT = 30; +const RESOLVE_ID = 'ProjectJSONContribution-'; + +const CACHE_EXPIRY = 1000 * 60 * 5; // 5 minutes + +interface NugetServices { + 'SearchQueryService'?: string; + 'SearchAutocompleteService'?: string; + 'PackageBaseAddress/3.0.0'?: string; + [key: string]: string; +} export class ProjectJSONContribution implements IJSONWorkerContribution { private requestService : IRequestService; - + private cachedProjects: { [id: string]: { version: string, description: string, time: number }} = {}; + private cacheSize: number = 0; + private nugetIndexPromise: Thenable; + public constructor(requestService: IRequestService) { this.requestService = requestService; } @@ -24,6 +39,67 @@ export class ProjectJSONContribution implements IJSONWorkerContribution { private isProjectJSONFile(resource: string): boolean { return Strings.endsWith(resource, '/project.json'); } + + private completeWithCache(id: string, item: CompletionItem) : boolean { + let entry = this.cachedProjects[id]; + if (entry) { + if (new Date().getTime() - entry.time > CACHE_EXPIRY) { + delete this.cachedProjects[id]; + this.cacheSize--; + return false; + } + item.detail = entry.version; + item.documentation = entry.description; + item.insertText = item.insertText.replace(/\{\{\}\}/, '{{' + entry.version + '}}'); + return true; + } + return false; + } + + private addCached(id: string, version: string, description: string) { + this.cachedProjects[id] = { version, description, time: new Date().getTime()}; + this.cacheSize++; + if (this.cacheSize > 50) { + let currentTime = new Date().getTime() ; + for (var id in this.cachedProjects) { + let entry = this.cachedProjects[id]; + if (currentTime - entry.time > CACHE_EXPIRY) { + delete this.cachedProjects[id]; + this.cacheSize--; + } + } + } + } + + private getNugetIndex() : Thenable { + if (!this.nugetIndexPromise) { + this.nugetIndexPromise = this.makeJSONRequest(FEED_INDEX_URL).then(indexContent => { + let services : NugetServices = {}; + if (indexContent && Array.isArray(indexContent.resources)) { + let resources = indexContent.resources; + for (let i = resources.length - 1; i >= 0; i--) { + let type = resources[i]['@type']; + let id = resources[i]['@id']; + if (type && id) { + services[type] = id; + } + } + } + return services; + }); + } + return this.nugetIndexPromise; + } + + private getNugetService(serviceType: string) : Thenable { + return this.getNugetIndex().then(services => { + let serviceURL = services[serviceType]; + if (!serviceURL) { + return Promise.reject(nls.localize('json.nugget.error.missingservice', 'NuGet index document is missing service {0}', serviceType)); + } + return serviceURL; + }); + } public collectDefaultSuggestions(resource: string, result: ISuggestionsCollector): Thenable { if (this.isProjectJSONFile(resource)) { @@ -40,106 +116,88 @@ export class ProjectJSONContribution implements IJSONWorkerContribution { return null; } - public collectPropertySuggestions(resource: string, location: JSONLocation, currentWord: string, addValue: boolean, isLast:boolean, result: ISuggestionsCollector) : Thenable { - if (this.isProjectJSONFile(resource) && (location.matches(['dependencies']) || location.matches(['frameworks', '*', 'dependencies']) || location.matches(['frameworks', '*', 'frameworkAssemblies']))) { - let queryUrl : string; - if (currentWord.length > 0) { - queryUrl = 'https://www.nuget.org/api/v2/Packages?' - + '$filter=Id%20ge%20\'' - + encodeURIComponent(currentWord) - + '\'%20and%20Id%20lt%20\'' - + encodeURIComponent(currentWord + 'z') - + '\'%20and%20IsAbsoluteLatestVersion%20eq%20true' - + '&$select=Id,Version,Description&$format=json&$top=' + LIMIT; - } else { - queryUrl = 'https://www.nuget.org/api/v2/Packages?' - + '$filter=IsAbsoluteLatestVersion%20eq%20true' - + '&$orderby=DownloadCount%20desc&$top=' + LIMIT - + '&$select=Id,Version,DownloadCount,Description&$format=json'; - } - - return this.requestService({ - url : queryUrl - }).then((success) => { - if (success.status === 200) { - try { - let obj = JSON.parse(success.responseText); - if (Array.isArray(obj.d)) { - let results = obj.d; - for (let i = 0; i < results.length; i++) { - let curr = results[i]; - let name = curr.Id; - let version = curr.Version; - if (name) { - let documentation = curr.Description; - let typeLabel = curr.Version; - let insertText = JSON.stringify(name); - if (addValue) { - insertText += ': "{{' + version + '}}"'; - if (!isLast) { - insertText += ','; - } - } - result.add({ kind: CompletionItemKind.Property, label: name, insertText: insertText, detail: typeLabel, documentation: documentation }); - } - } - if (results.length === LIMIT) { - result.setAsIncomplete(); - } - } - } catch (e) { - // ignore - } - } else { - result.error(nls.localize('json.nugget.error.repoaccess', 'Request to the nuget repository failed: {0}', success.responseText)); - return 0; + private makeJSONRequest(url: string) : Thenable { + return this.requestService({ + url : url + }).then(success => { + if (success.status === 200) { + try { + return JSON.parse(success.responseText); + } catch (e) { + return Promise.reject(nls.localize('json.nugget.error.invalidformat', '{0} is not a valid JSON document', url)); } - }, (error) => { - result.error(nls.localize('json.nugget.error.repoaccess', 'Request to the nuget repository failed: {0}', error.responseText)); - return 0; - }); - } - return null; + } + return Promise.reject(nls.localize('json.nugget.error.indexaccess', 'Request to {0} failed: {1}', url, success.responseText)); + }, (error: IXHRResponse) => { + return Promise.reject(nls.localize('json.nugget.error.access', 'Request to {0} failed: {1}', url, getErrorStatusDescription(error.status))); + }); } - public collectValueSuggestions(resource: string, location: JSONLocation, currentKey: string, result: ISuggestionsCollector): Thenable { + public collectPropertySuggestions(resource: string, location: JSONLocation, currentWord: string, addValue: boolean, isLast:boolean, result: ISuggestionsCollector) : Thenable { if (this.isProjectJSONFile(resource) && (location.matches(['dependencies']) || location.matches(['frameworks', '*', 'dependencies']) || location.matches(['frameworks', '*', 'frameworkAssemblies']))) { - let queryUrl = 'https://www.myget.org/F/aspnetrelease/api/v2/Packages?' - + '$filter=Id%20eq%20\'' - + encodeURIComponent(currentKey) - + '\'&$select=Version,IsAbsoluteLatestVersion&$format=json&$top=' + LIMIT; - return this.requestService({ - url : queryUrl - }).then((success) => { - try { - let obj = JSON.parse(success.responseText); - if (Array.isArray(obj.d)) { - let results = obj.d; + return this.getNugetService('SearchAutocompleteService').then(service => { + let queryUrl : string; + if (currentWord.length > 0) { + queryUrl = service + '?q=' + encodeURIComponent(currentWord) +'&take=' + LIMIT; + } else { + queryUrl = service + '?take=' + LIMIT; + } + return this.makeJSONRequest(queryUrl).then(resultObj => { + if (Array.isArray(resultObj.data)) { + let results = resultObj.data; for (let i = 0; i < results.length; i++) { - let curr = results[i]; - let version = curr.Version; - if (version) { - let name = JSON.stringify(version); - let isLatest = curr.IsAbsoluteLatestVersion === 'true'; - let label = name; - let documentation = ''; - if (isLatest) { - documentation = nls.localize('json.nugget.versiondescription.suggest', 'The currently latest version of the package'); + let name = results[i]; + let insertText = JSON.stringify(name); + if (addValue) { + insertText += ': "{{}}"'; + if (!isLast) { + insertText += ','; } - result.add({ kind: CompletionItemKind.Class, label: label, insertText: name, documentation: documentation }); } + let item : CompletionItem = { kind: CompletionItemKind.Property, label: name, insertText: insertText }; + if (!this.completeWithCache(name, item)) { + item.data = RESOLVE_ID + name; + } + result.add(item); } if (results.length === LIMIT) { result.setAsIncomplete(); } } - } catch (e) { - // ignore - } - return 0; - }, (error) => { - return 0; + }, error => { + result.error(error); + }); + }, error => { + result.error(error); + }); + }; + return null; + } + + public collectValueSuggestions(resource: string, location: JSONLocation, currentKey: string, result: ISuggestionsCollector): Thenable { + if (this.isProjectJSONFile(resource) && (location.matches(['dependencies']) || location.matches(['frameworks', '*', 'dependencies']) || location.matches(['frameworks', '*', 'frameworkAssemblies']))) { + return this.getNugetService('PackageBaseAddress/3.0.0').then(service => { + let queryUrl = service + currentKey + '/index.json'; + return this.makeJSONRequest(queryUrl).then(obj => { + if (Array.isArray(obj.versions)) { + let results = obj.versions; + for (let i = 0; i < results.length; i++) { + let curr = results[i]; + let name = JSON.stringify(curr); + let label = name; + let documentation = ''; + result.add({ kind: CompletionItemKind.Class, label: label, insertText: name, documentation: documentation }); + } + if (results.length === LIMIT) { + result.setAsIncomplete(); + } + } + }, error => { + result.error(error); + }); + }, error => { + result.error(error); }); } return null; @@ -149,40 +207,63 @@ export class ProjectJSONContribution implements IJSONWorkerContribution { if (this.isProjectJSONFile(resource) && (location.matches(['dependencies', '*']) || location.matches(['frameworks', '*', 'dependencies', '*']) || location.matches(['frameworks', '*', 'frameworkAssemblies', '*']))) { let pack = location.getSegments()[location.getSegments().length - 1]; - let htmlContent : MarkedString[] = []; - htmlContent.push(nls.localize('json.nugget.package.hover', '{0}', pack)); - - let queryUrl = 'https://www.myget.org/F/aspnetrelease/api/v2/Packages?' - + '$filter=Id%20eq%20\'' - + encodeURIComponent(pack) - + '\'%20and%20IsAbsoluteLatestVersion%20eq%20true' - + '&$select=Version,Description&$format=json&$top=5'; - - return this.requestService({ - url : queryUrl - }).then((success) => { - let content = success.responseText; - if (content) { - try { - let obj = JSON.parse(content); - if (obj.d && obj.d[0]) { - let res = obj.d[0]; - if (res.Description) { - htmlContent.push(res.Description); - } - if (res.Version) { - htmlContent.push(nls.localize('json.nugget.version.hover', 'Latest version: {0}', res.Version)); + return this.getNugetService('SearchQueryService').then(service => { + let queryUrl = service + '?q=' + encodeURIComponent(pack) +'&take=' + 5; + return this.makeJSONRequest(queryUrl).then(resultObj => { + let htmlContent : MarkedString[] = []; + htmlContent.push(nls.localize('json.nugget.package.hover', '{0}', pack)); + if (Array.isArray(resultObj.data)) { + let results = resultObj.data; + for (let i = 0; i < results.length; i++) { + let res = results[i]; + this.addCached(res.id, res.version, res.description); + if (res.id === pack) { + if (res.description) { + htmlContent.push(res.description); + } + if (res.version) { + htmlContent.push(nls.localize('json.nugget.version.hover', 'Latest version: {0}', res.version)); + } + break; } } - } catch (e) { - // ignore } - } - return htmlContent; + return htmlContent; + }, (error) => { + return null; + }); }, (error) => { - return htmlContent; + return null; }); } return null; } + + public resolveSuggestion(item: CompletionItem) : Thenable { + if (item.data && Strings.startsWith(item.data, RESOLVE_ID)) { + let pack = item.data.substring(RESOLVE_ID.length); + if (this.completeWithCache(pack, item)) { + return Promise.resolve(item); + } + return this.getNugetService('SearchQueryService').then(service => { + let queryUrl = service + '?q=' + encodeURIComponent(pack) +'&take=' + 10; + return this.makeJSONRequest(queryUrl).then(resultObj => { + let itemResolved = false; + if (Array.isArray(resultObj.data)) { + let results = resultObj.data; + for (let i = 0; i < results.length; i++) { + let curr = results[i]; + this.addCached(curr.id, curr.version, curr.description); + if (curr.id === pack) { + this.completeWithCache(pack, item); + itemResolved = true; + } + } + } + return itemResolved ? item : null; + }); + }); + }; + return null; + } } \ No newline at end of file diff --git a/extensions/json/server/src/server.ts b/extensions/json/server/src/server.ts index 8e26304b2a2..effa2d3c201 100644 --- a/extensions/json/server/src/server.ts +++ b/extensions/json/server/src/server.ts @@ -9,7 +9,7 @@ import { createConnection, IConnection, TextDocuments, ITextDocument, Diagnostic, DiagnosticSeverity, InitializeParams, InitializeResult, TextDocumentIdentifier, TextDocumentPosition, CompletionList, - Hover, SymbolInformation, DocumentFormattingParams, + CompletionItem, Hover, SymbolInformation, DocumentFormattingParams, DocumentRangeFormattingParams, NotificationType, RequestType } from 'vscode-languageserver'; @@ -63,7 +63,7 @@ connection.onInitialize((params: InitializeParams): InitializeResult => { capabilities: { // Tell the client that the server works in FULL text document sync mode textDocumentSync: documents.syncKind, - completionProvider: { resolveProvider: false }, + completionProvider: { resolveProvider: true }, hoverProvider: true, documentSymbolProvider: true, documentRangeFormattingProvider: true, @@ -121,7 +121,7 @@ let contributions = [ let jsonSchemaService = new JSONSchemaService(request, workspaceContext, telemetry); jsonSchemaService.setSchemaContributions(schemaContributions); -let jsonCompletion = new JSONCompletion(jsonSchemaService, contributions); +let jsonCompletion = new JSONCompletion(jsonSchemaService, connection.console, contributions); let jsonHover = new JSONHover(jsonSchemaService, contributions); let jsonDocumentSymbols = new JSONDocumentSymbols(); @@ -269,6 +269,10 @@ connection.onCompletion((textDocumentPosition: TextDocumentPosition): Thenable => { + return jsonCompletion.doResolve(item); +}); + connection.onHover((textDocumentPosition: TextDocumentPosition): Thenable => { let document = documents.get(textDocumentPosition.uri); let jsonDocument = getJSONDocument(document); diff --git a/extensions/json/server/src/test/completion.test.ts b/extensions/json/server/src/test/completion.test.ts index 3872cc36294..b23b80bbb24 100644 --- a/extensions/json/server/src/test/completion.test.ts +++ b/extensions/json/server/src/test/completion.test.ts @@ -36,7 +36,7 @@ suite('JSON Completion', () => { var idx = stringAfter ? value.indexOf(stringAfter) : 0; var schemaService = new SchemaService.JSONSchemaService(requestService); - var completionProvider = new JSONCompletion(schemaService); + var completionProvider = new JSONCompletion(schemaService, console); if (schema) { var id = "http://myschemastore/test1"; schemaService.registerExternalSchema(id, ["*.json"], schema);