coc-tsserver/src/server/features/completionItemProvider.ts
2022-09-12 12:09:05 +08:00

462 lines
16 KiB
TypeScript

import { commands, CompletionItemProvider, TextDocument, CompletionList, CompletionItem, window, workspace } from 'coc.nvim'
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { CancellationToken, Command, CompletionContext, InsertTextFormat, MarkupContent, MarkupKind, Position, Range, TextEdit } from 'vscode-languageserver-protocol'
import Proto from '../protocol'
import * as PConst from '../protocol.const'
import { ITypeScriptServiceClient, ServerResponse } from '../typescriptService'
import API from '../utils/api'
import { applyCodeAction } from '../utils/codeAction'
import { convertCompletionEntry, DotAccessorContext, getParameterListParts } from '../utils/completionItem'
import * as Previewer from '../utils/previewer'
import SnippetString from '../utils/SnippetString'
import * as typeConverters from '../utils/typeConverters'
import TypingsStatus from '../utils/typingsStatus'
import FileConfigurationManager, { SuggestOptions } from './fileConfigurationManager'
// command center
export interface CommandItem {
readonly id: string | string[]
execute(...args: any[]): void | Promise<any>
}
class ApplyCompletionCodeActionCommand implements CommandItem {
public static readonly ID = '_typescript.applyCompletionCodeAction'
public readonly id = ApplyCompletionCodeActionCommand.ID
public constructor(
private readonly client: ITypeScriptServiceClient
) {
}
// apply code action on complete
public async execute(codeActions: Proto.CodeAction[]): Promise<void> {
if (codeActions.length === 0) {
return
}
if (codeActions.length === 1) {
await applyCodeAction(this.client, codeActions[0])
return
}
const idx = await window.showQuickpick(codeActions.map(o => o.description), 'Select code action to apply')
if (idx < 0) return
const action = codeActions[idx]
await applyCodeAction(this.client, action)
return
}
}
export default class TypeScriptCompletionItemProvider implements CompletionItemProvider {
public static readonly triggerCharacters = ['.', '"', '\'', '`', '/', '@', '<', '#', ' ']
private completeOption: SuggestOptions
constructor(
private readonly client: ITypeScriptServiceClient,
private readonly typingsStatus: TypingsStatus,
private readonly fileConfigurationManager: FileConfigurationManager,
languageId: string
) {
this.setCompleteOption(languageId)
commands.registerCommand(ApplyCompletionCodeActionCommand.ID, async (codeActions) => {
let cmd = new ApplyCompletionCodeActionCommand(this.client)
await cmd.execute(codeActions)
})
workspace.onDidChangeConfiguration(_e => {
this.setCompleteOption(languageId)
})
}
private setCompleteOption(languageId: string): void {
this.completeOption = this.fileConfigurationManager.getCompleteOptions(languageId)
}
/**
* Get completionItems
*
* @public
* @param {TextDocument} document
* @param {Position} position
* @param {CancellationToken} token
* @param {string} triggerCharacter
* @returns {Promise<CompletionItem[]>}
*/
public async provideCompletionItems(
document: TextDocument,
position: Position,
token: CancellationToken,
context: CompletionContext,
): Promise<CompletionList | null> {
if (this.typingsStatus.isAcquiringTypings) {
return Promise.resolve({
isIncomplete: true,
items: [{
label: 'Acquiring typings...',
detail: 'Acquiring typings definitions for IntelliSense.'
}]
})
}
let { uri } = document
const file = this.client.toPath(document.uri)
if (!file) return null
let preText = document.getText({
start: { line: position.line, character: 0 },
end: position
})
let { triggerCharacter, option } = context as any
if (!this.shouldTrigger(triggerCharacter, preText, option)) {
return null
}
await this.client.interruptGetErr(() => this.fileConfigurationManager.ensureConfigurationForDocument(document, token))
const { completeOption } = this
const args: Proto.CompletionsRequestArgs & { includeAutomaticOptionalChainCompletions?: boolean } = {
...typeConverters.Position.toFileLocationRequestArgs(file, position),
includeExternalModuleExports: completeOption.autoImports,
includeInsertTextCompletions: true,
triggerCharacter: this.getTsTriggerCharacter(context),
includeAutomaticOptionalChainCompletions: completeOption.includeAutomaticOptionalChainCompletions
}
let entries: ReadonlyArray<Proto.CompletionEntry> | undefined
let dotAccessorContext: DotAccessorContext | undefined
let isNewIdentifierLocation = true
let isMemberCompletion = false
let isIncomplete = false
const isInValidCommitCharacterContext = this.isInValidCommitCharacterContext(document, position)
if (this.client.apiVersion.gte(API.v300)) {
try {
const response = await this.client.interruptGetErr(() => this.client.execute('completionInfo', args, token))
if (response.type !== 'response' || !response.body) {
return null
}
isNewIdentifierLocation = response.body.isNewIdentifierLocation
isMemberCompletion = response.body.isMemberCompletion
if (isMemberCompletion) {
const dotMatch = preText.slice(0, position.character).match(/\??\.\s*$/) || undefined
if (dotMatch) {
const range = Range.create({
line: position.line,
character: position.character - dotMatch.length
}, position)
const text = document.getText(range)
dotAccessorContext = { range, text }
}
}
isIncomplete = !!response.body.isIncomplete || (response as any).metadata && (response as any).metadata.isIncomplete
entries = response.body.entries
} catch (e) {
if (e.message == 'No content available.') {
return null
}
throw e
}
} else {
const response = await this.client.interruptGetErr(() => this.client.execute('completions', args, token))
if (response.type !== 'response' || !response.body) {
return null
}
entries = response.body
}
const completionItems: CompletionItem[] = []
for (const element of entries) {
if (shouldExcludeCompletionEntry(element, completeOption)) {
continue
}
const item = convertCompletionEntry(
element,
uri,
position,
{
isNewIdentifierLocation,
isMemberCompletion,
enableCallCompletions: completeOption.completeFunctionCalls,
isInValidCommitCharacterContext,
dotAccessorContext,
}
)
completionItems.push(item)
}
return { isIncomplete, items: completionItems }
}
private getTsTriggerCharacter(context: CompletionContext): Proto.CompletionsTriggerCharacter | undefined {
// return context.triggerCharacter as Proto.CompletionsTriggerCharacter
switch (context.triggerCharacter) {
case '@': // Workaround for https://github.com/Microsoft/TypeScript/issues/27321
return this.client.apiVersion.gte(API.v310) && this.client.apiVersion.lt(API.v320) ? undefined : '@'
case '#': // Workaround for https://github.com/microsoft/TypeScript/issues/36367
return this.client.apiVersion.lt(API.v381) ? undefined : '#'
case ' ':
return this.client.apiVersion.gte(API.v430) ? ' ' : undefined
case '.':
case '"':
case '\'':
case '`':
case '/':
case '<':
return context.triggerCharacter
}
return undefined
}
/**
* Resolve complete item, could have documentation added
*
* @public
* @param {CompletionItem} item
* @param {CancellationToken} token
* @returns {Promise<CompletionItem>}
*/
public async resolveCompletionItem(
item: CompletionItem,
token: CancellationToken
): Promise<CompletionItem> {
if (item == null) return undefined
let { uri, position, source, name, data } = item.data
const filepath = this.client.toPath(uri)
if (!filepath) return undefined
const args: Proto.CompletionDetailsRequestArgs = {
...typeConverters.Position.toFileLocationRequestArgs(
filepath,
position
),
entryNames: [source ? { name, source, data } : name]
}
let response: ServerResponse.Response<Proto.CompletionDetailsResponse>
try {
response = await this.client.interruptGetErr(() => this.client.execute('completionEntryDetails', args, token))
} catch {
return item
}
if (response.type !== 'response' || !response.body || !response.body.length) {
return item
}
const details = response.body
if (!details || !details.length || !details[0]) {
return item
}
const detail = details[0]
if (!item.detail && detail.displayParts.length) {
item.detail = Previewer.plainWithLinks(detail.displayParts)
}
item.documentation = this.getDocumentation(detail)
const { command, additionalTextEdits } = this.getCodeActions(detail, filepath)
if (command) item.command = command
item.additionalTextEdits = additionalTextEdits
if (detail && item.insertTextFormat == InsertTextFormat.Snippet) {
const shouldCompleteFunction = await this.isValidFunctionCompletionContext(filepath, position, token)
if (shouldCompleteFunction) {
this.createSnippetOfFunctionCall(item, detail)
}
}
return item
}
private getCodeActions(
detail: Proto.CompletionEntryDetails,
filepath: string
): { command?: Command; additionalTextEdits?: TextEdit[] } {
if (!detail.codeActions || !detail.codeActions.length) {
return {}
}
// Try to extract out the additionalTextEdits for the current file.
// Also check if we still have to apply other workspace edits
const additionalTextEdits: TextEdit[] = []
let hasRemainingCommandsOrEdits = false
for (const tsAction of detail.codeActions) {
if (tsAction.commands) {
hasRemainingCommandsOrEdits = true
}
// Convert all edits in the current file using `additionalTextEdits`
if (tsAction.changes) {
for (const change of tsAction.changes) {
if (change.fileName === filepath) {
additionalTextEdits.push(
...change.textChanges.map(typeConverters.TextEdit.fromCodeEdit)
)
} else {
hasRemainingCommandsOrEdits = true
}
}
}
}
let command = null
if (hasRemainingCommandsOrEdits) {
// Create command that applies all edits not in the current file.
command = {
title: '',
command: ApplyCompletionCodeActionCommand.ID,
arguments: [
detail.codeActions.map((x): Proto.CodeAction => ({
commands: x.commands,
description: x.description,
changes: x.changes.filter(x => x.fileName !== filepath)
}))
]
}
}
return {
command,
additionalTextEdits: additionalTextEdits.length
? additionalTextEdits
: undefined
}
}
private shouldTrigger(
triggerCharacter: string,
pre: string,
option: any
): boolean {
if (triggerCharacter && this.client.apiVersion.lt(API.v290)) {
if (triggerCharacter === '@') {
// trigger in string
if (option.synname && /string/i.test(option.synname)) {
return true
}
// make sure we are in something that looks like the start of a jsdoc comment
if (!pre.match(/^\s*\*[ ]?@/) && !pre.match(/\/\*\*+[ ]?@/)) {
return false
}
} else if (triggerCharacter === '<') {
return false
}
}
if (triggerCharacter === ' ') {
if (!this.completeOption.importStatementSuggestions || !this.client.apiVersion.lt(API.v430)) {
return false
}
return pre === 'import '
}
return true
}
// complete item documentation
private getDocumentation(detail: Proto.CompletionEntryDetails): MarkupContent | undefined {
let documentation = ''
if (detail.source) {
const importPath = `'${Previewer.plainWithLinks(detail.source)}'`
const autoImportLabel = `Auto import from ${importPath}`
documentation += `${autoImportLabel}\n`
}
let parts = [
Previewer.plainWithLinks(detail.documentation),
Previewer.tagsMarkdownPreview(detail.tags)
]
parts = parts.filter(s => s && s.trim() != '')
documentation += parts.join('\n\n')
if (documentation.length) {
return {
kind: MarkupKind.Markdown,
value: documentation
}
}
return undefined
}
private createSnippetOfFunctionCall(
item: CompletionItem,
detail: Proto.CompletionEntryDetails
): void {
let { displayParts } = detail
const parameterListParts = getParameterListParts(displayParts)
const snippet = new SnippetString()
snippet.appendText(`${item.insertText ?? item.label}(`)
appendJoinedPlaceholders(snippet, parameterListParts.parts, ', ')
if (parameterListParts.hasOptionalParameters) {
snippet.appendTabstop()
}
snippet.appendText(')')
snippet.appendTabstop(0)
item.insertText = snippet.value
}
private async isValidFunctionCompletionContext(
filepath: string,
position: Position,
token: CancellationToken
): Promise<boolean> {
// Workaround for https://github.com/Microsoft/TypeScript/issues/12677
// Don't complete function calls inside of destructive assigments or imports
try {
const args: Proto.FileLocationRequestArgs = typeConverters.Position.toFileLocationRequestArgs(filepath, position)
const response = await this.client.execute('quickinfo', args, token)
if (response.type !== 'response') {
return true
}
const { body } = response
switch (body && body.kind) {
case 'var':
case 'let':
case 'const':
case 'alias':
return false
default:
return true
}
} catch (e) {
return true
}
}
private isInValidCommitCharacterContext(
document: TextDocument,
position: Position
): boolean {
if (this.client.apiVersion.lt(API.v320)) {
// Workaround for https://github.com/Microsoft/TypeScript/issues/27742
// Only enable dot completions when previous character not a dot preceded by whitespace.
// Prevents incorrectly completing while typing spread operators.
if (position.character > 1) {
const preText = document.getText(Range.create(
position.line, 0,
position.line, position.character))
return preText.match(/(\s|^)\.$/ig) === null
}
}
return true
}
}
function shouldExcludeCompletionEntry(
element: Proto.CompletionEntry,
completionConfiguration: SuggestOptions
): boolean {
return (
(!completionConfiguration.names && element.kind === PConst.Kind.warning)
|| (!completionConfiguration.paths &&
(element.kind === PConst.Kind.directory || element.kind === PConst.Kind.script || element.kind === PConst.Kind.externalModuleName))
|| (!completionConfiguration.autoImports && element.hasAction)
)
}
function appendJoinedPlaceholders(
snippet: SnippetString,
parts: ReadonlyArray<Proto.SymbolDisplayPart>,
joiner: string
): void {
for (let i = 0; i < parts.length; ++i) {
const paramterPart = parts[i]
snippet.appendPlaceholder(paramterPart.text)
if (i !== parts.length - 1) {
snippet.appendText(joiner)
}
}
}