'use strict' import { Neovim } from '@chemzqm/neovim' import { CancellationToken, CancellationTokenSource, Emitter, Event, Position } from 'vscode-languageserver-protocol' import Document from '../model/document' import { CompleteOption, CompleteResult, ExtendedCompleteItem, FloatConfig, ISource, VimCompleteItem } from '../types' import { wait } from '../util' import { getCharCodes } from '../util/fuzzy' import { byteSlice, characterIndex, isWord } from '../util/string' import { matchScore } from './match' import MruLoader from './mru' const isVim = process.env.VIM_NODE_RPC == '1' const logger = require('../util/logger')('completion-complete') export interface CompleteConfig { selection: 'none' | 'recentlyUsed' | 'recentlyUsedByPrefix' disableKind: boolean disableMenu: boolean disableMenuShortcut: boolean enablePreview: boolean enablePreselect: boolean labelMaxLength: number floatEnable: boolean autoTrigger: string previewIsKeyword: string triggerCompletionWait: number minTriggerInputLength: number triggerAfterInsertEnter: boolean acceptSuggestionOnCommitCharacter: boolean noselect: boolean keepCompleteopt: boolean maxItemCount: number timeout: number snippetIndicator: string fixInsertedWord: boolean localityBonus: boolean highPrioritySourceLimit: number lowPrioritySourceLimit: number removeDuplicateItems: boolean defaultSortMethod: string asciiCharactersOnly: boolean ignoreRegexps: string[] floatConfig: FloatConfig } export type Callback = () => void export default class Complete { // identify this complete private results: Map = new Map() private _input = '' private _completing = false private localBonus: Map = new Map() // source names that already filtered. private filtered: Set = new Set() private tokenSource: CancellationTokenSource private timer: NodeJS.Timer private names: string[] = [] private readonly _onDidRefresh = new Emitter() public readonly onDidRefresh: Event = this._onDidRefresh.event constructor(public option: CompleteOption, private document: Document, private config: CompleteConfig, private sources: ISource[], private mruLoader: MruLoader, private nvim: Neovim) { this.tokenSource = new CancellationTokenSource() sources.sort((a, b) => b.priority - a.priority) this.names = sources.map(o => o.name) } private fireRefresh(waitTime: number): void { if (this.timer) clearTimeout(this.timer) this.timer = setTimeout(() => { if (this.allFiltered) return this._onDidRefresh.fire() }, waitTime) } private get allFiltered(): boolean { let { filtered, results } = this if (filtered.size === 0) return false for (let key of results.keys()) { if (!filtered.has(key)) return false } return true } public get isCompleting(): boolean { return this._completing } public get input(): string { return this._input } public get isEmpty(): boolean { let empty = true for (let res of this.results.values()) { if (res.items.length > 0) { empty = false break } } return empty } public getIncompleteSources(): string[] { let names: string[] = [] for (let [name, result] of this.results.entries()) { if (result.isIncomplete) { names.push(name) } } return names } public async doComplete(): Promise { let token = this.tokenSource.token let res = await Promise.all([ this.nvim.call('coc#util#synname', []), this.document.patchChange() ]) this.option.synname = res[0] if (token.isCancellationRequested) return true let { triggerCompletionWait, localityBonus } = this.config await wait(Math.min(triggerCompletionWait ?? 0, 50)) if (token.isCancellationRequested) return true let { colnr, linenr, col } = this.option if (localityBonus) { let line = linenr - 1 this.localBonus = this.document.getLocalifyBonus(Position.create(line, col - 1), Position.create(line, colnr)) } await this.completeSources(this.sources) return token.isCancellationRequested } private async completeSources(sources: ReadonlyArray): Promise { let { fixInsertedWord, timeout } = this.config let { results, tokenSource, } = this let col = this.option.col let isFilter = results.size > 0 let followPart = !fixInsertedWord ? '' : this.getFollowPart() let names = sources.map(s => s.name) let total = names.length this._completing = true let token = tokenSource.token let timer: NodeJS.Timer let tp = new Promise(resolve => { timer = setTimeout(() => { if (!tokenSource.token.isCancellationRequested) { names = names.filter(n => !finished.includes(n)) tokenSource.cancel() logger.warn(`Complete timeout after ${timeout}ms`, names) this.nvim.setVar(`coc_timeout_sources`, names, true) } resolve() }, typeof timeout === 'number' ? timeout : 500) }) const finished: string[] = [] await Promise.race([ tp, Promise.all(sources.map(s => this.completeSource(s, token, followPart).then(() => { finished.push(s.name) if (token.isCancellationRequested || isFilter) return let colChanged = this.option.col !== col if (colChanged) this.cancel() if (colChanged || finished.length === total) { this.fireRefresh(0) } else if (results.has(s.name)) { this.fireRefresh(16) } })))]) clearTimeout(timer) this._completing = false } private async completeSource(source: ISource, token: CancellationToken, followPart: string): Promise { // new option for each source let opt = Object.assign({}, this.option) let { snippetIndicator } = this.config let { name } = source try { if (typeof source.shouldComplete === 'function') { let shouldRun = await Promise.resolve(source.shouldComplete(opt)) if (!shouldRun || token.isCancellationRequested) return } const priority = source.priority ?? 0 const start = Date.now() await new Promise((resolve, reject) => { Promise.resolve(source.doComplete(opt, token)).then(result => { let len = result ? result.items.length : 0 if (token.isCancellationRequested) { resolve(undefined) return } logger.debug(`Source "${name}" finished with ${len} items ${Date.now() - start}ms`) if (len > 0) { result.priority = priority let hasFollow = followPart.length > 0 result.items.forEach((item, idx) => { let word = item.word ?? '' let abbr = item.abbr ?? word item.word = word item.source = name item.priority = priority item.filterText = item.filterText ?? word if (hasFollow && word != followPart && word.endsWith(followPart)) { item.word = word.slice(0, - followPart.length) } if (item.isSnippet === true && !abbr.endsWith(snippetIndicator)) item.abbr = `${abbr}${snippetIndicator}` item.localBonus = this.localBonus.get(item.filterText) || 0 item.user_data = `${name}:${idx}` }) this.setResult(name, result) } else { this.results.delete(name) } resolve() }, err => { reject(err) }) }) } catch (err) { this.nvim.echoError(err) logger.error('Complete error:', source.name, err) } } public async completeInComplete(resumeInput: string, names: string[]): Promise { let { document } = this this.cancel() this.tokenSource = new CancellationTokenSource() let token = this.tokenSource.token await document.patchChange(true) if (token.isCancellationRequested) return undefined let { input, colnr, linenr } = this.option let character = resumeInput[resumeInput.length - 1] Object.assign(this.option, { input: resumeInput, line: document.getline(linenr - 1), colnr: colnr + (resumeInput.length - input.length), triggerCharacter: !character || isWord(character) ? undefined : character, triggerForInComplete: true }) let sources = this.sources.filter(s => names.includes(s.name)) await this.completeSources(sources) if (token.isCancellationRequested) return undefined return this.filterItems(resumeInput) } public filterItems(input: string): ExtendedCompleteItem[] | undefined { let { results, names } = this this._input = input if (results.size == 0) return [] let len = input.length let emptyInput = len == 0 let { maxItemCount, selection, enablePreselect, defaultSortMethod, removeDuplicateItems } = this.config let arr: ExtendedCompleteItem[] = [] let codes = getCharCodes(input) let words: Set = new Set() let maxMru = -1 let checkMru = selection !== 'none' for (let name of names) { let result = results.get(name) if (!result) continue let snippetSource = name === 'snippets' let items = result.items for (let idx = 0; idx < items.length; idx++) { let item = items[idx] let { word, filterText, dup } = item if (dup !== 1 && words.has(word)) continue if (filterText.length < len) continue if (removeDuplicateItems && item.isSnippet !== true && words.has(word)) continue if (!emptyInput) { let score = item.kind && filterText == input ? 64 : matchScore(filterText, codes) if (score === 0) continue if (snippetSource && word === input) { item.score = 99 } else { item.score = score * (item.sourceScore || 1) } } if (checkMru) { let n = this.mruLoader.getScore(input, item) maxMru = Math.max(n, maxMru) item.recentScore = n } words.add(word) arr.push(item) } } arr.sort((a, b) => { let sa = a.sortText let sb = b.sortText if (a.score !== b.score) return b.score - a.score if (a.priority !== b.priority) return b.priority - a.priority if (a.localBonus !== b.localBonus) return b.localBonus - a.localBonus if (a.source === b.source && sa !== sb) return sa < sb ? -1 : 1 // not sort with empty input if (input.length === 0) return 0 switch (defaultSortMethod) { case 'none': return 0 case 'alphabetical': return a.filterText.localeCompare(b.filterText) case 'length': default: // Fallback on length return a.filterText.length - b.filterText.length } }) let sourceNames = results.keys() process.nextTick(() => { let { results } = this for (let name of sourceNames) { let result = results.get(name) if (result) result.items = arr.filter(o => o.source === name) } }) if (maxMru !== -1) { let idx = arr.findIndex(o => o.recentScore === maxMru) if (enablePreselect && !isVim) { arr[idx].preselect = true } else { let removed = arr.splice(idx, 1) arr.unshift(removed[0]) } } return this.limitCompleteItems(arr.slice(0, maxItemCount)) } public async filterResults(input: string): Promise { this.filtered = new Set(this.results.keys()) if (input !== this.option.input) { let names = this.getIncompleteSources() if (names.length) { return await this.completeInComplete(input, names) } } return this.filterItems(input) } private limitCompleteItems(items: ExtendedCompleteItem[]): ExtendedCompleteItem[] { let { highPrioritySourceLimit, lowPrioritySourceLimit } = this.config if (!highPrioritySourceLimit && !lowPrioritySourceLimit) return items let counts: Map = new Map() return items.filter(item => { let { priority, source } = item let isLow = priority < 90 let curr = counts.get(source) || 0 if ((lowPrioritySourceLimit && isLow && curr == lowPrioritySourceLimit) || (highPrioritySourceLimit && !isLow && curr == highPrioritySourceLimit)) { return false } counts.set(source, curr + 1) return true }) } // handle startcol change private setResult(name: string, result: CompleteResult): void { let { results } = this let { line, colnr, col } = this.option if (typeof result.startcol === 'number' && result.startcol != col) { let { startcol } = result this.option.col = startcol this.option.input = byteSlice(line, startcol, colnr - 1) results.clear() results.set(name, result) } else { results.set(name, result) } } private cancel(): void { let { tokenSource, timer } = this if (timer) clearTimeout(timer) tokenSource.cancel() this._completing = false } public resolveCompletionItem(item: VimCompleteItem | undefined): ExtendedCompleteItem | null { if (typeof item.user_data !== 'string') return null try { let arr = item.user_data.split(':', 2) let res = this.results.get(arr[0]) return res ? res.items.find(o => o.user_data == item.user_data) : null } catch (e) { return null } } private getFollowPart(): string { let { colnr, line } = this.option let idx = characterIndex(line, colnr - 1) if (idx == line.length) return '' let part = line.slice(idx - line.length) return part.match(/^\S?[\w-]*/)[0] } public dispose(): void { this.cancel() this._onDidRefresh.dispose() this.sources = [] this.filtered.clear() this.results.clear() } }