-
Notifications
You must be signed in to change notification settings - Fork 73
Snippet support for autocompletion #323
Changes from 5 commits
fa55f77
d3c49f2
be2cb4e
f62570f
cf5d05a
59e574f
a7c59d9
29d9ccf
a9cb785
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,11 +1,11 @@ | ||
import * as chai from 'chai'; | ||
import * as sinon from 'sinon'; | ||
import * as ts from 'typescript'; | ||
import { CompletionItemKind, CompletionList, DiagnosticSeverity, TextDocumentIdentifier, TextDocumentItem, WorkspaceEdit } from 'vscode-languageserver'; | ||
import { CompletionItemKind, CompletionList, DiagnosticSeverity, InsertTextFormat, TextDocumentIdentifier, TextDocumentItem, WorkspaceEdit } from 'vscode-languageserver'; | ||
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 { SymbolLocationInformation } from '../request-type'; | ||
import { ClientCapabilities, SymbolLocationInformation } from '../request-type'; | ||
import { TypeScriptService, TypeScriptServiceFactory } from '../typescript-service'; | ||
import { observableFromIterable, toUnixPath, uri2path } from '../util'; | ||
import chaiAsPromised = require('chai-as-promised'); | ||
|
@@ -16,6 +16,11 @@ import { IBeforeAndAfterContext, ISuiteCallbackContext, ITestCallbackContext } f | |
chai.use(chaiAsPromised); | ||
const assert = chai.assert; | ||
|
||
const defaultCapabilities: ClientCapabilities = { | ||
xcontentProvider: true, | ||
xfilesProvider: true | ||
}; | ||
|
||
export interface TestContext { | ||
|
||
/** TypeScript service under test */ | ||
|
@@ -31,7 +36,7 @@ export interface TestContext { | |
* @param createService A factory that creates the TypeScript service. Allows to test subclasses of TypeScriptService | ||
* @param files A Map from URI to file content of files that should be available in the workspace | ||
*/ | ||
export const initializeTypeScriptService = (createService: TypeScriptServiceFactory, rootUri: string, files: Map<string, string>) => async function (this: TestContext & IBeforeAndAfterContext): Promise<void> { | ||
export const initializeTypeScriptService = (createService: TypeScriptServiceFactory, rootUri: string, files: Map<string, string>, clientCapabilities?: ClientCapabilities) => async function (this: TestContext & IBeforeAndAfterContext): Promise<void> { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Put the default value into the parameter |
||
|
||
// Stub client | ||
this.client = sinon.createStubInstance(RemoteLanguageClient); | ||
|
@@ -56,10 +61,7 @@ export const initializeTypeScriptService = (createService: TypeScriptServiceFact | |
await this.service.initialize({ | ||
processId: process.pid, | ||
rootUri, | ||
capabilities: { | ||
xcontentProvider: true, | ||
xfilesProvider: true | ||
} | ||
capabilities: clientCapabilities || defaultCapabilities | ||
}).toPromise(); | ||
}; | ||
|
||
|
@@ -2123,6 +2125,91 @@ export function describeTypeScriptService(createService: TypeScriptServiceFactor | |
|
||
}); | ||
|
||
describe('textDocumentCompletion() with snippets', function (this: TestContext & ISuiteCallbackContext){ | ||
beforeEach(initializeTypeScriptService(createService, rootUri, new Map([ | ||
[rootUri + 'a.ts', [ | ||
'class A {', | ||
' /** foo doc*/', | ||
' foo() {}', | ||
' /** bar doc*/', | ||
' bar(num: number): number { return 1; }', | ||
' /** baz doc*/', | ||
' baz(num: number): string { return ""; }', | ||
' /** qux doc*/', | ||
' qux: number;', | ||
'}', | ||
'const a = new A();', | ||
'a.' | ||
].join('\n')] | ||
]), { | ||
textDocument: { | ||
completion: { | ||
completionItem: { | ||
snippetSupport: true | ||
} | ||
} | ||
}, | ||
...defaultCapabilities | ||
})); | ||
|
||
afterEach(shutdownService); | ||
|
||
it('should produce completions with snippets if supported', async function (this: TestContext & ITestCallbackContext) { | ||
const result: CompletionList = await this.service.textDocumentCompletion({ | ||
textDocument: { | ||
uri: rootUri + 'a.ts' | ||
}, | ||
position: { | ||
line: 11, | ||
character: 2 | ||
} | ||
}).reduce<Operation, CompletionList>(applyReducer, null as any).toPromise(); | ||
// * A snippet can define tab stops and placeholders with `$1`, `$2` | ||
// * and `${3:foo}`. `$0` defines the final tab stop, it defaults to | ||
// * 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, [ | ||
{ | ||
label: 'bar', | ||
kind: CompletionItemKind.Method, | ||
documentation: 'bar doc', | ||
sortText: '0', | ||
insertTextFormat: InsertTextFormat.Snippet, | ||
insertText: 'bar(${1:num})', | ||
detail: '(method) A.bar(num: number): number' | ||
}, | ||
{ | ||
label: 'baz', | ||
kind: CompletionItemKind.Method, | ||
documentation: 'baz doc', | ||
sortText: '0', | ||
insertTextFormat: InsertTextFormat.Snippet, | ||
insertText: 'baz(${1:num})', | ||
detail: '(method) A.baz(num: number): string' | ||
}, | ||
{ | ||
label: 'foo', | ||
kind: CompletionItemKind.Method, | ||
documentation: 'foo doc', | ||
sortText: '0', | ||
insertTextFormat: InsertTextFormat.Snippet, | ||
insertText: 'foo()', | ||
detail: '(method) A.foo(): void' | ||
}, | ||
{ | ||
label: 'qux', | ||
kind: CompletionItemKind.Property, | ||
documentation: 'qux doc', | ||
sortText: '0', | ||
insertTextFormat: InsertTextFormat.Snippet, | ||
insertText: 'qux', | ||
detail: '(property) A.qux: number' | ||
} | ||
]); | ||
}); | ||
}); | ||
|
||
describe('textDocumentCompletion()', function (this: TestContext & ISuiteCallbackContext) { | ||
beforeEach(initializeTypeScriptService(createService, rootUri, new Map([ | ||
[rootUri + 'a.ts', [ | ||
|
@@ -2175,32 +2262,41 @@ export function describeTypeScriptService(createService: TypeScriptServiceFactor | |
label: 'bar', | ||
kind: CompletionItemKind.Method, | ||
documentation: 'bar doc', | ||
insertText: 'bar', | ||
insertTextFormat: InsertTextFormat.PlainText, | ||
sortText: '0', | ||
detail: '(method) A.bar(): number' | ||
}, | ||
{ | ||
label: 'baz', | ||
kind: CompletionItemKind.Method, | ||
documentation: 'baz doc', | ||
insertText: 'baz', | ||
insertTextFormat: InsertTextFormat.PlainText, | ||
sortText: '0', | ||
detail: '(method) A.baz(): string' | ||
}, | ||
{ | ||
label: 'foo', | ||
kind: CompletionItemKind.Method, | ||
documentation: 'foo doc', | ||
insertText: 'foo', | ||
insertTextFormat: InsertTextFormat.PlainText, | ||
sortText: '0', | ||
detail: '(method) A.foo(): void' | ||
}, | ||
{ | ||
label: 'qux', | ||
kind: CompletionItemKind.Property, | ||
documentation: 'qux doc', | ||
insertText: 'qux', | ||
insertTextFormat: InsertTextFormat.PlainText, | ||
sortText: '0', | ||
detail: '(property) A.qux: number' | ||
} | ||
]); | ||
}); | ||
|
||
it('produces completions for imported symbols', async function (this: TestContext & ITestCallbackContext) { | ||
const result: CompletionList = await this.service.textDocumentCompletion({ | ||
textDocument: { | ||
|
@@ -2217,6 +2313,8 @@ export function describeTypeScriptService(createService: TypeScriptServiceFactor | |
label: 'd', | ||
kind: CompletionItemKind.Function, | ||
documentation: 'd doc', | ||
insertText: 'd', | ||
insertTextFormat: InsertTextFormat.PlainText, | ||
detail: 'function d(): void', | ||
sortText: '0' | ||
}] | ||
|
@@ -2238,6 +2336,8 @@ export function describeTypeScriptService(createService: TypeScriptServiceFactor | |
label: 'bar', | ||
kind: CompletionItemKind.Interface, | ||
documentation: 'bar doc', | ||
insertText: 'bar', | ||
insertTextFormat: InsertTextFormat.PlainText, | ||
sortText: '0', | ||
detail: 'interface foo.bar' | ||
}] | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -18,6 +18,7 @@ import { | |
DocumentSymbolParams, | ||
ExecuteCommandParams, | ||
Hover, | ||
InsertTextFormat, | ||
Location, | ||
MarkedString, | ||
ParameterInformation, | ||
|
@@ -173,6 +174,8 @@ export class TypeScriptService { | |
} | ||
}; | ||
|
||
private completionWithSnippets: boolean = false; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please add docblock There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The name is a bit vague. Please add a |
||
|
||
constructor(protected client: LanguageClient, protected options: TypeScriptServiceOptions = {}) { | ||
this.logger = new LSPLogger(client); | ||
} | ||
|
@@ -200,6 +203,10 @@ export class TypeScriptService { | |
if (params.rootUri || params.rootPath) { | ||
this.root = params.rootPath || uri2path(params.rootUri!); | ||
this.rootUri = params.rootUri || path2uri(params.rootPath!); | ||
|
||
if (params.capabilities.textDocument && params.capabilities.textDocument.completion && params.capabilities.textDocument.completion.completionItem) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. no need for the |
||
this.completionWithSnippets = params.capabilities.textDocument.completion.completionItem.snippetSupport || false; | ||
} | ||
// The root URI always refers to a directory | ||
if (!this.rootUri.endsWith('/')) { | ||
this.rootUri += '/'; | ||
|
@@ -998,10 +1005,26 @@ export class TypeScriptService { | |
if (completions == null) { | ||
return []; | ||
} | ||
function createSnippet(entry: ts.CompletionEntryDetails): string { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this is only used in one place, so no benefit to put in an inline function, but makes the code not chronological. Better to do it inline and document |
||
if (entry.kind === 'property') { | ||
return entry.name; | ||
} else { | ||
let index = 0; | ||
const parameters = entry.displayParts | ||
.filter(p => p.kind === 'parameterName') | ||
.map(p => { | ||
index++; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
return '${' + `${index}:${p.text}` + '}'; | ||
}); | ||
const paramString = parameters.join(', '); | ||
return entry.name + `(${paramString})`; | ||
} | ||
} | ||
|
||
return Observable.from(completions.entries) | ||
.map(entry => { | ||
const item: CompletionItem = { label: entry.name }; | ||
|
||
const kind = completionKinds[entry.kind]; | ||
if (kind) { | ||
item.kind = kind; | ||
|
@@ -1013,6 +1036,13 @@ export class TypeScriptService { | |
if (details) { | ||
item.documentation = ts.displayPartsToString(details.documentation); | ||
item.detail = ts.displayPartsToString(details.displayParts); | ||
if (this.completionWithSnippets) { | ||
item.insertTextFormat = InsertTextFormat.Snippet; | ||
item.insertText = createSnippet(details); | ||
} else { | ||
item.insertTextFormat = InsertTextFormat.PlainText; | ||
item.insertText = details.name; | ||
} | ||
} | ||
return { op: 'add', path: '/items/-', value: item } as Operation; | ||
}) | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
DEFAULT_CAPABILITIES