diff --git a/src/request-type.ts b/src/request-type.ts index e1b8fa31f..7cbd3c3e0 100644 --- a/src/request-type.ts +++ b/src/request-type.ts @@ -234,3 +234,30 @@ export interface PartialResultParams { */ patch: Operation[]; } + +/** + * Restriction on vscode's CompletionItem interface + */ +export interface CompletionItem extends vscode.CompletionItem { + data?: CompletionItemData; +} + +/** + * The necessary fields for a completion item details to be resolved by typescript + */ +export interface CompletionItemData { + /** + * The document from which the completion was requested + */ + uri: string; + + /** + * The offset into the document at which the completion was requested + */ + offset: number; + + /** + * The name field from typescript's returned completion entry + */ + entryName: string; +} diff --git a/src/test/typescript-service-helpers.ts b/src/test/typescript-service-helpers.ts index fe5271010..d4da745a5 100644 --- a/src/test/typescript-service-helpers.ts +++ b/src/test/typescript-service-helpers.ts @@ -9,7 +9,7 @@ import { CompletionItemKind, CompletionList, DiagnosticSeverity, InsertTextForma import { Command, Diagnostic, Hover, Location, SignatureHelp, SymbolInformation, SymbolKind } from 'vscode-languageserver-types'; import { LanguageClient, RemoteLanguageClient } from '../lang-handler'; import { DependencyReference, PackageInformation, ReferenceInformation, TextDocumentContentParams, WorkspaceFilesParams } from '../request-type'; -import { ClientCapabilities, SymbolLocationInformation } from '../request-type'; +import { ClientCapabilities, CompletionItem, SymbolLocationInformation } from '../request-type'; import { TypeScriptService, TypeScriptServiceFactory } from '../typescript-service'; import { observableFromIterable, toUnixPath, uri2path } from '../util'; @@ -2154,7 +2154,62 @@ export function describeTypeScriptService(createService: TypeScriptServiceFactor afterEach(shutdownService); - it('should produce completions with snippets if supported', async function (this: TestContext & ITestCallbackContext) { + it('should produce completions', async function (this: TestContext & ITestCallbackContext) { + const result: CompletionList = await this.service.textDocumentCompletion({ + textDocument: { + uri: rootUri + 'a.ts' + }, + position: { + line: 11, + character: 2 + } + }).reduce(applyReducer, null as any).toPromise(); + assert.equal(result.isIncomplete, false); + assert.sameDeepMembers(result.items, [ + { + label: 'bar', + kind: CompletionItemKind.Method, + sortText: '0', + data: { + entryName: 'bar', + offset: 210, + uri: rootUri + 'a.ts' + } + }, + { + label: 'baz', + kind: CompletionItemKind.Method, + sortText: '0', + data: { + entryName: 'baz', + offset: 210, + uri: rootUri + 'a.ts' + } + }, + { + label: 'foo', + kind: CompletionItemKind.Method, + sortText: '0', + data: { + entryName: 'foo', + offset: 210, + uri: rootUri + 'a.ts' + } + }, + { + label: 'qux', + kind: CompletionItemKind.Property, + sortText: '0', + data: { + entryName: 'qux', + offset: 210, + uri: rootUri + 'a.ts' + } + } + ]); + }); + + it('should resolve completions with snippets', async function (this: TestContext & ITestCallbackContext) { const result: CompletionList = await this.service.textDocumentCompletion({ textDocument: { uri: rootUri + 'a.ts' @@ -2169,7 +2224,16 @@ export function describeTypeScriptService(createService: TypeScriptServiceFactor // * the end of the snippet. Placeholders with equal identifiers are linked, // * that is typing in one will update others too. assert.equal(result.isIncomplete, false); - assert.sameDeepMembers(result.items, [ + + const resolvedItems = await Observable.from(result.items) + .mergeMap(item => this.service + .completionItemResolve(item) + .reduce(applyReducer, null as any) + ) + .toArray() + .toPromise(); + + assert.sameDeepMembers(resolvedItems, [ { label: 'bar', kind: CompletionItemKind.Method, @@ -2177,7 +2241,8 @@ export function describeTypeScriptService(createService: TypeScriptServiceFactor sortText: '0', insertTextFormat: InsertTextFormat.Snippet, insertText: 'bar(${1:num})', - detail: '(method) A.bar(num: number): number' + detail: '(method) A.bar(num: number): number', + data: undefined }, { label: 'baz', @@ -2186,7 +2251,8 @@ export function describeTypeScriptService(createService: TypeScriptServiceFactor sortText: '0', insertTextFormat: InsertTextFormat.Snippet, insertText: 'baz(${1:num})', - detail: '(method) A.baz(num: number): string' + detail: '(method) A.baz(num: number): string', + data: undefined }, { label: 'foo', @@ -2195,7 +2261,8 @@ export function describeTypeScriptService(createService: TypeScriptServiceFactor sortText: '0', insertTextFormat: InsertTextFormat.Snippet, insertText: 'foo()', - detail: '(method) A.foo(): void' + detail: '(method) A.foo(): void', + data: undefined }, { label: 'qux', @@ -2204,9 +2271,11 @@ export function describeTypeScriptService(createService: TypeScriptServiceFactor sortText: '0', insertTextFormat: InsertTextFormat.Snippet, insertText: 'qux', - detail: '(property) A.qux: number' + detail: '(property) A.qux: number', + data: undefined } ]); + }); }); @@ -2258,6 +2327,68 @@ export function describeTypeScriptService(createService: TypeScriptServiceFactor }).reduce(applyReducer, null as any).toPromise(); assert.equal(result.isIncomplete, false); assert.sameDeepMembers(result.items, [ + { + data: { + entryName: 'bar', + offset: 188, + uri: rootUri + 'a.ts' + }, + label: 'bar', + kind: CompletionItemKind.Method, + sortText: '0' + }, + { + data: { + entryName: 'baz', + offset: 188, + uri: rootUri + 'a.ts' + }, + label: 'baz', + kind: CompletionItemKind.Method, + sortText: '0' + }, + { + data: { + entryName: 'foo', + offset: 188, + uri: rootUri + 'a.ts' + }, + label: 'foo', + kind: CompletionItemKind.Method, + sortText: '0' + }, + { + data: { + entryName: 'qux', + offset: 188, + uri: rootUri + 'a.ts' + }, + label: 'qux', + kind: CompletionItemKind.Property, + sortText: '0' + } + ]); + }); + + it('resolves completions in the same file', async function (this: TestContext & ITestCallbackContext) { + const result: CompletionList = await this.service.textDocumentCompletion({ + textDocument: { + uri: rootUri + 'a.ts' + }, + position: { + line: 11, + character: 2 + } + }).reduce(applyReducer, null as any).toPromise(); + assert.equal(result.isIncomplete, false); + + const resolveItem = (item: CompletionItem) => this.service + .completionItemResolve(item) + .reduce(applyReducer, null as any).toPromise(); + + const resolvedItems = await Promise.all(result.items.map(resolveItem)); + + assert.sameDeepMembers(resolvedItems, [ { label: 'bar', kind: CompletionItemKind.Method, @@ -2265,7 +2396,8 @@ export function describeTypeScriptService(createService: TypeScriptServiceFactor insertText: 'bar', insertTextFormat: InsertTextFormat.PlainText, sortText: '0', - detail: '(method) A.bar(): number' + detail: '(method) A.bar(): number', + data: undefined }, { label: 'baz', @@ -2274,7 +2406,8 @@ export function describeTypeScriptService(createService: TypeScriptServiceFactor insertText: 'baz', insertTextFormat: InsertTextFormat.PlainText, sortText: '0', - detail: '(method) A.baz(): string' + detail: '(method) A.baz(): string', + data: undefined }, { label: 'foo', @@ -2283,7 +2416,8 @@ export function describeTypeScriptService(createService: TypeScriptServiceFactor insertText: 'foo', insertTextFormat: InsertTextFormat.PlainText, sortText: '0', - detail: '(method) A.foo(): void' + detail: '(method) A.foo(): void', + data: undefined }, { label: 'qux', @@ -2292,9 +2426,11 @@ export function describeTypeScriptService(createService: TypeScriptServiceFactor insertText: 'qux', insertTextFormat: InsertTextFormat.PlainText, sortText: '0', - detail: '(property) A.qux: number' + detail: '(property) A.qux: number', + data: undefined } ]); + }); it('produces completions for imported symbols', async function (this: TestContext & ITestCallbackContext) { @@ -2310,12 +2446,13 @@ export function describeTypeScriptService(createService: TypeScriptServiceFactor assert.deepEqual(result, { isIncomplete: false, items: [{ + data: { + entryName: 'd', + offset: 32, + uri: rootUri + 'uses-import.ts' + }, label: 'd', kind: CompletionItemKind.Function, - documentation: 'd doc', - insertText: 'd', - insertTextFormat: InsertTextFormat.PlainText, - detail: 'function d(): void', sortText: '0' }] }); @@ -2333,13 +2470,14 @@ export function describeTypeScriptService(createService: TypeScriptServiceFactor assert.deepEqual(result, { isIncomplete: false, items: [{ + data: { + entryName: 'bar', + offset: 51, + uri: rootUri + 'uses-reference.ts' + }, label: 'bar', kind: CompletionItemKind.Interface, - documentation: 'bar doc', - insertText: 'bar', - insertTextFormat: InsertTextFormat.PlainText, - sortText: '0', - detail: 'interface foo.bar' + sortText: '0' }] }); }); diff --git a/src/typescript-service.ts b/src/typescript-service.ts index 349987338..dcee67343 100644 --- a/src/typescript-service.ts +++ b/src/typescript-service.ts @@ -10,7 +10,6 @@ import * as url from 'url'; import { CodeActionParams, Command, - CompletionItem, CompletionItemKind, CompletionList, DidChangeConfigurationParams, @@ -44,6 +43,7 @@ import { InMemoryFileSystem, isTypeScriptLibrary } from './memfs'; import { extractDefinitelyTypedPackageName, extractNodeModulesPackageName, PackageJson, PackageManager } from './packages'; import { ProjectConfiguration, ProjectManager } from './project-manager'; import { + CompletionItem, DependencyReference, InitializeParams, InitializeResult, @@ -1022,26 +1022,14 @@ export class TypeScriptService { if (entry.sortText) { item.sortText = entry.sortText; } - const details = configuration.getService().getCompletionEntryDetails(fileName, offset, entry.name); - if (details) { - item.documentation = ts.displayPartsToString(details.documentation); - item.detail = ts.displayPartsToString(details.displayParts); - if (this.supportsCompletionWithSnippets) { - item.insertTextFormat = InsertTextFormat.Snippet; - if (entry.kind === 'property') { - item.insertText = details.name; - } else { - const parameters = details.displayParts - .filter(p => p.kind === 'parameterName') - .map((p, i) => '${' + `${i + 1}:${p.text}` + '}'); - const paramString = parameters.join(', '); - item.insertText = details.name + `(${paramString})`; - } - } else { - item.insertTextFormat = InsertTextFormat.PlainText; - item.insertText = details.name; - } - } + + // context for future resolve requests: + item.data = { + uri, + offset, + entryName: entry.name + }; + return { op: 'add', path: '/items/-', value: item } as Operation; }) .startWith({ op: 'add', path: '/isIncomplete', value: false } as Operation); @@ -1049,6 +1037,52 @@ export class TypeScriptService { .startWith({ op: 'add', path: '', value: { isIncomplete: true, items: [] } as CompletionList } as Operation); } + /** + * The completionItem/resolve request is used to fill in additional details from an incomplete + * CompletionItem returned from the textDocument/completions call. + * + * @return Observable of JSON Patches that build a `CompletionItem` result + */ + completionItemResolve(item: CompletionItem, span = new Span()): Observable { + if (!item.data) { + throw new Error('Cannot resolve completion item without data'); + } + const {uri, offset, entryName} = item.data; + const fileName: string = uri2path(uri); + return this.projectManager.ensureReferencedFiles(uri, undefined, undefined, span) + .toArray() + .map(() => { + + const configuration = this.projectManager.getConfiguration(fileName); + configuration.ensureBasicFiles(span); + + const details = configuration.getService().getCompletionEntryDetails(fileName, offset, entryName); + if (details) { + item.documentation = ts.displayPartsToString(details.documentation); + item.detail = ts.displayPartsToString(details.displayParts); + if (this.supportsCompletionWithSnippets) { + item.insertTextFormat = InsertTextFormat.Snippet; + if (details.kind === 'method' || details.kind === 'function') { + const parameters = details.displayParts + .filter(p => p.kind === 'parameterName') + .map((p, i) => '${' + `${i + 1}:${p.text}` + '}'); + const paramString = parameters.join(', '); + item.insertText = details.name + `(${paramString})`; + } else { + item.insertText = details.name; + + } + } else { + item.insertTextFormat = InsertTextFormat.PlainText; + item.insertText = details.name; + } + item.data = undefined; + } + return item; + }) + .map(completionItem => ({ op: 'add', path: '', value: completionItem }) as Operation); + } + /** * The signature help request is sent from the client to the server to request signature * information at a given cursor position.