'use strict' import { CancellationToken, CompletionItem, CompletionItemKind, CompletionTriggerKind, DocumentSelector, InsertReplaceEdit, InsertTextFormat, Position, Range, TextEdit } from 'vscode-languageserver-protocol' import commands from '../commands' import Document from '../model/document' import { CompletionItemProvider } from '../provider' import snippetManager from '../snippets/manager' import { SnippetParser } from '../snippets/parser' import { CompleteOption, CompleteResult, ExtendedCompleteItem, ISource, SourceType } from '../types' import { fuzzyMatch, getCharCodes } from '../util/fuzzy' import { byteIndex, byteLength, byteSlice, characterIndex } from '../util/string' import window from '../window' import workspace from '../workspace' const logger = require('../util/logger')('source-language') export interface CompleteConfig { labels: Map snippetsSupport: boolean defaultKindText: string priority: number detailMaxLength: number detailField: string invalidInsertCharacters: string[] floatEnable: boolean } export default class LanguageSource implements ISource { public priority: number public sourceType: SourceType.Service private _enabled = true private filetype: string private completeItems: CompletionItem[] = [] constructor( public readonly name: string, public readonly shortcut: string, private provider: CompletionItemProvider, public readonly documentSelector: DocumentSelector, public readonly triggerCharacters: string[], public readonly allCommitCharacters: string[], priority: number | undefined, private readonly completeConfig: CompleteConfig, ) { this.priority = typeof priority === 'number' ? priority : completeConfig.priority } public get enable(): boolean { return this._enabled } public toggle(): void { this._enabled = !this._enabled } public shouldCommit?(item: ExtendedCompleteItem, character: string): boolean { let completeItem = this.completeItems[item.index] if (!completeItem) return false let commitCharacters = [...this.allCommitCharacters, ...(completeItem.commitCharacters || [])] return commitCharacters.includes(character) } public async doComplete(opt: CompleteOption, token: CancellationToken): Promise { let { triggerCharacter, input, bufnr } = opt this.filetype = opt.filetype this.completeItems = [] let triggerKind: CompletionTriggerKind = this.getTriggerKind(opt) let position = this.getPosition(opt) let context: any = { triggerKind, option: opt } if (triggerKind == CompletionTriggerKind.TriggerCharacter) context.triggerCharacter = triggerCharacter let doc = workspace.getAttachedDocument(bufnr) let result = await Promise.resolve(this.provider.provideCompletionItems(doc.textDocument, position, token, context)) if (!result || token.isCancellationRequested) return null let completeItems = Array.isArray(result) ? result : result.items if (!completeItems || completeItems.length == 0) return null this.completeItems = completeItems let startcol = getStartColumn(opt.line, completeItems) let option: CompleteOption = Object.assign({}, opt) let prefix: string let isIncomplete = typeof result['isIncomplete'] === 'boolean' ? result['isIncomplete'] : false if (startcol == null && input.length > 0 && this.triggerCharacters.includes(opt.triggerCharacter)) { if (!completeItems.every(item => (item.insertText ?? item.label).startsWith(opt.input))) { startcol = opt.col + byteLength(opt.input) } } if (startcol != null) { prefix = startcol < option.col ? byteSlice(opt.line, startcol, option.col) : '' option.col = startcol } let items: ExtendedCompleteItem[] = completeItems.map((o, index) => { let item = this.convertVimCompleteItem(o, this.shortcut, option, prefix) item.index = index return item }) return { startcol, isIncomplete, items } } public async onCompleteResolve(item: ExtendedCompleteItem, token: CancellationToken): Promise { let { index } = item let completeItem = this.completeItems[index] if (!completeItem || item.resolved) return let hasResolve = typeof this.provider.resolveCompletionItem === 'function' if (hasResolve) { let resolved = await Promise.resolve(this.provider.resolveCompletionItem(completeItem, token)) if (token.isCancellationRequested || !resolved) return Object.assign(completeItem, resolved) } if (typeof item.documentation === 'undefined') { let { documentation, detail } = completeItem if (!documentation && !detail) return let docs = [] if (detail && !item.detailShown && detail != item.word) { detail = detail.replace(/\n\s*/g, ' ') if (detail.length) { let isText = /^[\w-\s.,\t\n]+$/.test(detail) docs.push({ filetype: isText ? 'txt' : this.filetype, content: detail }) } } if (documentation) { if (typeof documentation == 'string') { docs.push({ filetype: 'txt', content: documentation }) } else if (documentation.value) { docs.push({ filetype: documentation.kind == 'markdown' ? 'markdown' : 'txt', content: documentation.value }) } } item.resolved = true item.documentation = docs } } public async onCompleteDone(vimItem: ExtendedCompleteItem, opt: CompleteOption): Promise { let item = this.completeItems[vimItem.index] if (!item) return if (typeof vimItem.line === 'string') Object.assign(opt, { line: vimItem.line }) let doc = workspace.getAttachedDocument(opt.bufnr) await doc.patchChange(true) let additionalEdits = Array.isArray(item.additionalTextEdits) && item.additionalTextEdits.length > 0 if (additionalEdits) { let shouldCancel = await snippetManager.editsInsideSnippet(item.additionalTextEdits) if (shouldCancel) snippetManager.cancel() } let version = doc.version let isSnippet = await this.applyTextEdit(doc, additionalEdits, item, vimItem.word, opt) if (additionalEdits) { // move cursor after edit await doc.applyEdits(item.additionalTextEdits, doc.version != version, !isSnippet) if (isSnippet) await snippetManager.selectCurrentPlaceholder() } if (item.command) { if (commands.has(item.command.command)) { await commands.execute(item.command) } else { logger.warn(`Command "${item.command.command}" not registered to coc.nvim`) } } } private async applyTextEdit(doc: Document, additionalEdits: boolean, item: CompletionItem, word: string, option: CompleteOption): Promise { let { line, linenr, colnr, col } = option let pos = await window.getCursorPosition() if (pos.line != linenr - 1) return let { textEdit } = item let currline = doc.getline(linenr - 1) // before CompleteDone let beginIdx = characterIndex(line, colnr - 1) if (!textEdit && item.insertText) { textEdit = { range: Range.create(pos.line, characterIndex(line, col), pos.line, beginIdx), newText: item.insertText } } if (!textEdit) return false let newText = textEdit.newText let range = InsertReplaceEdit.is(textEdit) ? textEdit.replace : textEdit.range // adjust range by indent let n = fixIndent(line, currline, range) if (n) beginIdx += n // attempt to fix range from textEdit, range should include trigger position if (range.end.character < beginIdx) range.end.character = beginIdx // fix range by count cursor moved to replace insernt word on complete done. if (pos.character > beginIdx) range.end.character += pos.character - beginIdx let isSnippet = item.insertTextFormat === InsertTextFormat.Snippet if (isSnippet && this.completeConfig.snippetsSupport === false) { // could be wrong, but maybe best we can do. isSnippet = false newText = word } if (isSnippet) { let opts = item.data?.ultisnip === true ? {} : item.data?.ultisnip return await snippetManager.insertSnippet(newText, !additionalEdits, range, item.insertTextMode, opts ? opts : undefined) } await doc.applyEdits([TextEdit.replace(range, newText)], false, pos) return false } private getTriggerKind(opt: CompleteOption): CompletionTriggerKind { let { triggerCharacters } = this let isTrigger = triggerCharacters.includes(opt.triggerCharacter) let triggerKind: CompletionTriggerKind = CompletionTriggerKind.Invoked if (opt.triggerForInComplete) { triggerKind = CompletionTriggerKind.TriggerForIncompleteCompletions } else if (isTrigger) { triggerKind = CompletionTriggerKind.TriggerCharacter } return triggerKind } private convertVimCompleteItem(item: CompletionItem, shortcut: string, opt: CompleteOption, prefix: string): ExtendedCompleteItem { let { detailMaxLength, invalidInsertCharacters, detailField, labels, defaultKindText } = this.completeConfig let hasAdditionalEdit = item.additionalTextEdits != null && item.additionalTextEdits.length > 0 let isSnippet = item.insertTextFormat === InsertTextFormat.Snippet || hasAdditionalEdit let label = item.label.trim() let obj: ExtendedCompleteItem = { word: getWord(item, opt, invalidInsertCharacters), abbr: label, menu: `[${shortcut}]`, kind: getKindString(item.kind, labels, defaultKindText), sortText: item.sortText || null, sourceScore: item['score'] || null, filterText: item.filterText || label, isSnippet, dup: item.data && item.data.dup == 0 ? 0 : 1 } if (prefix) { if (!obj.filterText.startsWith(prefix)) { if (item.textEdit && fuzzyMatch(getCharCodes(prefix), item.textEdit.newText)) { obj.filterText = item.textEdit.newText.replace(/\r?\n/g, '') } } if (!item.textEdit && !obj.word.startsWith(prefix)) { // fix possible wrong word obj.word = `${prefix}${obj.word}` } } if (item && item.detail && detailField != 'preview') { let detail = item.detail.replace(/\n\s*/g, ' ') if (byteLength(detail) < detailMaxLength) { if (detailField == 'menu') { obj.menu = `${detail} ${obj.menu}` } else if (detailField == 'abbr') { obj.abbr = `${obj.abbr} - ${detail}` } obj.detailShown = 1 } } if (item.documentation) { obj.info = typeof item.documentation == 'string' ? item.documentation : item.documentation.value } else { obj.info = '' } if (obj.word == '') obj.empty = 1 obj.line = opt.line if (item.kind == CompletionItemKind.Folder && !obj.abbr.endsWith('/')) { obj.abbr = obj.abbr + '/' } if (item.preselect) obj.preselect = true if (item.data?.optional) obj.abbr = obj.abbr + '?' return obj } private getPosition(opt: CompleteOption): Position { let { line, linenr, colnr } = opt let part = byteSlice(line, 0, colnr - 1) return { line: linenr - 1, character: part.length } } } /* * Check new startcol by check start characters. */ export function getStartColumn(line: string, items: CompletionItem[]): number | undefined { let first = items[0] if (first.textEdit == null) return undefined let range = InsertReplaceEdit.is(first.textEdit) ? first.textEdit.replace : first.textEdit.range let { character } = range.start for (let i = 1; i < Math.min(10, items.length); i++) { let o = items[i] if (!o.textEdit) return undefined let r = InsertReplaceEdit.is(o.textEdit) ? o.textEdit.replace : o.textEdit.range if (r.start.character !== character) return undefined } return byteIndex(line, character) } export function getKindString(kind: CompletionItemKind, map: Map, defaultValue = ''): string { return map.get(kind) || defaultValue } export function getWord(item: CompletionItem, opt: CompleteOption, invalidInsertCharacters: string[]): string { let { label, data, insertTextFormat, insertText, textEdit } = item let word: string let newText: string if (data && typeof data.word === 'string') return data.word if (textEdit) { let range = InsertReplaceEdit.is(textEdit) ? textEdit.replace : textEdit.range newText = textEdit.newText if (range && range.start.line == range.end.line) { let { line, col, colnr } = opt let character = characterIndex(line, col) if (range.start.character > character) { let before = line.slice(character, range.start.character) newText = before + newText } else { let start = line.slice(range.start.character, character) if (start.length && newText.startsWith(start)) { newText = newText.slice(start.length) } } character = characterIndex(line, colnr - 1) if (range.end.character > character) { let end = line.slice(character, range.end.character) if (newText.endsWith(end)) { newText = newText.slice(0, - end.length) } } } } else if (insertText) { newText = insertText } if (insertTextFormat == InsertTextFormat.Snippet && newText && newText.includes('$')) { let parser = new SnippetParser() let text = parser.text(newText) word = text ? getValidWord(text, invalidInsertCharacters) : label } else { word = getValidWord(newText, invalidInsertCharacters) || label } return word || '' } export function getValidWord(text: string, invalidChars: string[], start = 2): string { if (!text) return '' if (!invalidChars.length) return text for (let i = start; i < text.length; i++) { let c = text[i] if (invalidChars.includes(c)) { return text.slice(0, i) } } return text } export function fixIndent(line: string, currline: string, range: Range): number { let oldIndent = line.match(/^\s*/)[0] let newIndent = currline.match(/^\s*/)[0] if (oldIndent == newIndent) return let d = newIndent.length - oldIndent.length range.start.character += d range.end.character += d return d }