1
0
Fork 0
mirror of synced 2024-06-26 02:31:09 -04:00
ultimate-vim/sources_non_forked/coc.nvim/src/completion/index.ts
2022-07-20 13:20:15 +08:00

601 lines
22 KiB
TypeScript

'use strict'
import { Neovim } from '@chemzqm/neovim'
import { CancellationTokenSource, Disposable } from 'vscode-languageserver-protocol'
import { URI } from 'vscode-uri'
import events, { InsertChange, PopupChangeEvent } from '../events'
import Document from '../model/document'
import sources from '../sources'
import { CompleteOption, ExtendedCompleteItem, FloatConfig, ISource, VimCompleteItem } from '../types'
import { disposeAll } from '../util'
import * as Is from '../util/is'
import { equals } from '../util/object'
import { byteLength, byteSlice, characterIndex, isWord } from '../util/string'
import workspace from '../workspace'
import Complete, { CompleteConfig } from './complete'
import Floating, { PumBounding } from './floating'
import MruLoader from './mru'
import { shouldIndent, shouldStop, waitInsertEvent, waitTextChangedI } from './util'
const logger = require('../util/logger')('completion')
const completeItemKeys = ['abbr', 'menu', 'info', 'kind', 'icase', 'dup', 'empty', 'user_data']
export interface LastInsert {
character: string
timestamp: number
}
export class Completion implements Disposable {
public config: CompleteConfig
private nvim: Neovim
private pretext: string | undefined
private hasInsert = false
private activated = false
private changedtick: number
private triggerTimer: NodeJS.Timer
private popupEvent: PopupChangeEvent
private floating: Floating
private disposables: Disposable[] = []
private complete: Complete | null = null
private resolveTokenSource: CancellationTokenSource
// saved for commit character.
private previousItem: VimCompleteItem | {} | undefined
private mru: MruLoader
public init(): void {
this.nvim = workspace.nvim
this.config = this.getCompleteConfig()
this.mru = new MruLoader(this.config.selection)
void this.mru.load()
workspace.onDidChangeConfiguration(e => {
if (e.affectsConfiguration('suggest')) {
this.config = this.getCompleteConfig()
}
}, null, this.disposables)
this.floating = new Floating(workspace.nvim, workspace.env.isVim)
events.on('InsertLeave', () => {
this.stop()
}, null, this.disposables)
events.on('CursorMovedI', (bufnr, cursor, hasInsert) => {
if (this.triggerTimer) clearTimeout(this.triggerTimer)
if (hasInsert || !this.option || bufnr !== this.option.bufnr) return
if (this.option.linenr === cursor[0]) {
let doc = workspace.getDocument(bufnr)
let line = doc.getline(cursor[0] - 1)
let idx = characterIndex(line, cursor[1] - 1)
let start = characterIndex(line, this.option.colnr - 1)
if (start < idx) {
let text = line.substring(start, idx)
if (doc.isWord(text)) return
}
}
this.stop()
}, null, this.disposables)
events.on('InsertEnter', this.onInsertEnter, this, this.disposables)
events.on('TextChangedP', this.onTextChangedP, this, this.disposables)
events.on('TextChangedI', this.onTextChangedI, this, this.disposables)
events.on('CompleteDone', async item => {
this.previousItem = this.popupEvent?.completed_item
this.popupEvent = null
this.hasInsert = false
if (!this.activated || item.closed) return
this.cancelResolve()
if (item.close) return this.stop()
if (!Is.vimCompleteItem(item)) {
let ev = await waitInsertEvent()
if (ev == 'CursorMovedI') this.stop()
} else {
await this.onCompleteDone(item)
}
}, null, this.disposables)
events.on('MenuPopupChanged', async ev => {
if (!this.activated || this.document?.isCommandLine) return
if (equals(this.popupEvent, ev)) return
this.cancelResolve()
this.popupEvent = ev
await this.onPumChange()
}, null, this.disposables)
}
public get option(): CompleteOption {
if (!this.complete) return null
return this.complete.option
}
private get selectedItem(): VimCompleteItem | null {
if (!this.popupEvent) return null
let { completed_item } = this.popupEvent
return Is.vimCompleteItem(completed_item) ? completed_item : null
}
public get isActivated(): boolean {
return this.activated
}
private get document(): Document | null {
if (!this.option) return null
return workspace.getDocument(this.option.bufnr)
}
private getCompleteConfig(): CompleteConfig {
let suggest = workspace.getConfiguration('suggest')
function getConfig<T>(key, defaultValue: T): T {
return suggest.get<T>(key, defaultValue)
}
let keepCompleteopt = getConfig<boolean>('keepCompleteopt', false)
let autoTrigger = getConfig<string>('autoTrigger', 'always')
if (keepCompleteopt && autoTrigger != 'none') {
let { completeOpt } = workspace
if (!completeOpt.includes('noinsert') && !completeOpt.includes('noselect')) {
keepCompleteopt = false
this.nvim.echoError('suggest.keepCompleteopt disabled, completeopt should includes noinsert or noselect')
}
}
let floatEnable = workspace.floatSupported && getConfig<boolean>('floatEnable', true)
let acceptSuggestionOnCommitCharacter = workspace.env.pumevent && getConfig<boolean>('acceptSuggestionOnCommitCharacter', false)
return {
autoTrigger,
floatEnable,
keepCompleteopt,
selection: getConfig<'none' | 'recentlyUsed' | 'recentlyUsedByPrefix'>('selection', 'recentlyUsed'),
floatConfig: getConfig<FloatConfig>('floatConfig', {}),
defaultSortMethod: getConfig<string>('defaultSortMethod', 'length'),
removeDuplicateItems: getConfig<boolean>('removeDuplicateItems', false),
disableMenuShortcut: getConfig<boolean>('disableMenuShortcut', false),
acceptSuggestionOnCommitCharacter,
disableKind: getConfig<boolean>('disableKind', false),
disableMenu: getConfig<boolean>('disableMenu', false),
previewIsKeyword: getConfig<string>('previewIsKeyword', '@,48-57,_192-255'),
enablePreview: getConfig<boolean>('enablePreview', false),
enablePreselect: getConfig<boolean>('enablePreselect', false),
triggerCompletionWait: getConfig<number>('triggerCompletionWait', 0),
labelMaxLength: getConfig<number>('labelMaxLength', 200),
triggerAfterInsertEnter: getConfig<boolean>('triggerAfterInsertEnter', false),
noselect: getConfig<boolean>('noselect', true),
maxItemCount: getConfig<number>('maxCompleteItemCount', 50),
timeout: getConfig<number>('timeout', 500),
minTriggerInputLength: getConfig<number>('minTriggerInputLength', 1),
snippetIndicator: getConfig<string>('snippetIndicator', '~'),
fixInsertedWord: getConfig<boolean>('fixInsertedWord', true),
localityBonus: getConfig<boolean>('localityBonus', true),
highPrioritySourceLimit: getConfig<number>('highPrioritySourceLimit', null),
lowPrioritySourceLimit: getConfig<number>('lowPrioritySourceLimit', null),
ignoreRegexps: getConfig<string[]>('ignoreRegexps', []),
asciiCharactersOnly: getConfig<boolean>('asciiCharactersOnly', false)
}
}
public async startCompletion(option: CompleteOption, sourceList?: ISource[]): Promise<void> {
try {
let doc = workspace.getAttachedDocument(option.bufnr)
option.filetype = doc.filetype
logger.debug('trigger completion with', option)
this.stop()
this.pretext = byteSlice(option.line, 0, option.colnr - 1)
sourceList = sourceList ?? this.getSources(option)
if (!sourceList || sourceList.length === 0) return
events.completing = true
this.changedtick = option.changedtick
let complete = this.complete = new Complete(
option,
doc,
this.config,
sourceList,
this.mru,
this.nvim)
complete.onDidRefresh(async () => {
if (this.triggerTimer != null) {
clearTimeout(this.triggerTimer)
}
if (complete.isEmpty) {
this.stop()
return
}
if (this.hasInsert) return
await this.filterResults()
})
await complete.doComplete()
} catch (e) {
this.stop()
this.nvim.echoError(e)
}
}
public getSources(option: CompleteOption): ISource[] {
let { source } = option
if (source) {
let s = sources.getSource(source)
return s ? [s] : []
}
return sources.getCompleteSources(option)
}
public hasSelected(): boolean {
if (workspace.env.pumevent) return this.selectedItem != null
// it's not correct
if (!this.config.noselect) return true
return false
}
private showCompletion(items: ExtendedCompleteItem[]): void {
let { nvim, option, changedtick } = this
if (!option) return
let { disableKind, labelMaxLength, disableMenuShortcut, disableMenu } = this.config
let preselect = this.config.enablePreselect ? items.findIndex(o => o.preselect) : -1
let validKeys = completeItemKeys.slice()
if (disableKind) validKeys = validKeys.filter(s => s != 'kind')
if (disableMenu) validKeys = validKeys.filter(s => s != 'menu')
let vimItems = items.map(item => {
let obj = { word: item.word, equal: 1 }
for (let key of validKeys) {
if (item.hasOwnProperty(key)) {
if (disableMenuShortcut && key == 'menu') {
obj[key] = item[key].replace(/\[.+\]$/, '')
} else if (key == 'abbr' && item[key].length > labelMaxLength) {
obj[key] = item[key].slice(0, labelMaxLength)
} else {
obj[key] = item[key]
}
}
}
return obj
})
nvim.pauseNotification()
if (vimItems.length) this.start()
nvim.call('coc#_do_complete', [option.col, vimItems, preselect, changedtick], true)
nvim.resumeNotification(false, true)
}
private async onTextChangedP(bufnr: number, info: InsertChange): Promise<void> {
let { option, document } = this
if (!option || option.bufnr != bufnr) return
if ((info.insertChar || this.pretext == info.pre) && shouldIndent(option.indentkeys, info.pre)) {
logger.debug(`trigger indent by ${info.pre}`)
let indentChanged = await this.nvim.call('coc#complete_indent', [])
if (indentChanged) return
}
this.changedtick = info.changedtick
if (this.pretext == info.pre) return
let pretext = this.pretext = info.pre
if (info.pre.match(/^\s*/)[0] !== option.line.match(/^\s*/)[0]) {
this.stop()
let res = await events.race(['TextChangedI', 'InsertCharPre'], 50)
if (res.name === 'TextChangedI') {
await this.triggerCompletion(document, res.args[1] as InsertChange)
}
return
}
// Avoid resume when TextChangedP caused by <C-n> or <C-p>
if (this.selectedItem && !info.insertChar) {
let expected = byteSlice(option.line, 0, option.col) + this.selectedItem.word
if (expected == pretext) {
this.hasInsert = true
return
}
}
await this.filterResults()
}
private async onTextChangedI(bufnr: number, info: InsertChange): Promise<void> {
if (!workspace.isAttached(bufnr) || this.config.autoTrigger === 'none') return
if (this.option && shouldStop(bufnr, this.pretext, info, this.option)) {
this.stop()
if (!info.insertChar) return
}
this.changedtick = info.changedtick
if (info.pre === this.pretext) return
if (this.triggerTimer) clearTimeout(this.triggerTimer)
let pretext = this.pretext = info.pre
let doc = workspace.getDocument(bufnr)
// check commit
if (this.activated && this.config.acceptSuggestionOnCommitCharacter && Is.vimCompleteItem(this.previousItem)) {
let resolvedItem = this.getCompleteItem(this.previousItem)
let last = pretext.slice(-1)
if (sources.shouldCommit(resolvedItem, last)) {
logger.debug('commit by commit character.')
let { linenr, col, line, colnr } = this.option
this.stop()
let { word } = resolvedItem
let newLine = `${line.slice(0, col)}${word}${info.insertChar}${line.slice(colnr - 1)}`
await this.nvim.call('coc#util#setline', [linenr, newLine])
let curcol = col + word.length + 2
await this.nvim.call('cursor', [linenr, curcol])
await doc.patchChange()
return
}
}
// trigger character
if (info.insertChar && !isWord(info.insertChar)) {
let disabled = doc.getVar('disabled_sources', [])
let triggerSources = sources.getTriggerSources(pretext, doc.filetype, doc.uri, disabled)
if (triggerSources.length > 0) {
await this.triggerCompletion(doc, info, triggerSources)
return
}
}
// trigger by normal character
if (!this.complete) {
if (!info.insertChar) return
await this.triggerCompletion(doc, info)
return
}
if (info.insertChar && this.complete.isEmpty) {
// triggering without results
this.triggerTimer = setTimeout(async () => {
await this.triggerCompletion(doc, info)
}, 200)
return
}
await this.filterResults()
}
private async triggerCompletion(doc: Document, info: InsertChange, sources?: ISource[]): Promise<boolean> {
let { minTriggerInputLength } = this.config
let { pre } = info
// check trigger
if (!sources) {
let shouldTrigger = this.shouldTrigger(doc, pre)
if (!shouldTrigger) return false
}
let disable = doc.getVar<number>('suggest_disable')
if (disable) {
logger.warn(`Completion of ${doc.bufnr} disabled by b:coc_suggest_disable`)
return false
}
let input = this.getInput(doc, pre)
let option: CompleteOption = {
input,
line: info.line,
filetype: doc.filetype,
linenr: info.lnum,
col: info.col - 1 - byteLength(input),
colnr: info.col,
bufnr: doc.bufnr,
word: input + this.getPrependWord(doc, info.line.slice(pre.length)),
changedtick: info.changedtick,
indentkeys: doc.indentkeys,
synname: '',
filepath: doc.schema === 'file' ? URI.parse(doc.uri).fsPath : '',
triggerCharacter: pre.length ? pre.slice(-1) : undefined,
blacklist: doc.getVar<string[]>('suggest_blacklist', []),
disabled: doc.getVar<string[]>('disabled_sources', []),
}
if (sources == null && input.length < minTriggerInputLength) {
logger.warn(`Suggest not triggered with input "${input}", minimal trigger input length: ${minTriggerInputLength}`)
return false
}
if (option.blacklist && option.blacklist.includes(option.input)) {
logger.warn(`Suggest disabled by b:coc_suggest_blacklist`, option.blacklist)
return false
}
if (this.config.ignoreRegexps.length > 0 && option.input.length > 0) {
const ignore = this.config.ignoreRegexps.some(regexp => {
if (new RegExp(regexp).test(option.input)) {
logger.warn(`Suggest disabled by ignore regexp: ${regexp}`)
return true
}
})
if (ignore) return false
}
// if (pre.length) option.triggerCharacter = pre[pre.length - 1]
await this.startCompletion(option, sources)
return true
}
private async onCompleteDone(item: VimCompleteItem): Promise<void> {
let { document, complete } = this
if (!document || !Is.vimCompleteItem(item)) return
let input = complete.input
let opt = Object.assign({}, this.option)
let resolvedItem = this.getCompleteItem(item)
this.stop()
if (!resolvedItem) return
this.mru.add(input, resolvedItem)
let insertChange = await waitTextChangedI()
if (typeof insertChange === 'string') return
if (insertChange && (insertChange.lnum != opt.linenr || insertChange.pre !== byteSlice(opt.line, 0, opt.col) + item.word)) return
let res = await events.race(['InsertCharPre', 'CursorMovedI'], 20)
if (res) return
let source = new CancellationTokenSource()
let { token } = source
await this.doCompleteResolve(resolvedItem, source)
if (token.isCancellationRequested) return
await this.doCompleteDone(resolvedItem, opt)
}
private doCompleteResolve(item: ExtendedCompleteItem, tokenSource: CancellationTokenSource): Promise<void> {
let source = sources.getSource(item.source)
return new Promise<void>(resolve => {
if (source && typeof source.onCompleteResolve === 'function') {
let timer = setTimeout(() => {
tokenSource.cancel()
logger.warn(`Resolve timeout after 500ms: ${source.name}`)
resolve()
}, 500)
Promise.resolve(source.onCompleteResolve(item, tokenSource.token)).then(() => {
clearTimeout(timer)
resolve()
}, e => {
logger.error(`Error on complete resolve: ${e.message}`, e)
clearTimeout(timer)
resolve()
})
} else {
resolve()
}
})
}
public async doCompleteDone(item: ExtendedCompleteItem, opt: CompleteOption): Promise<void> {
let source = sources.getSource(item.source)
if (source && typeof source.onCompleteDone === 'function') {
await Promise.resolve(source.onCompleteDone(item, opt))
}
}
private async onInsertEnter(bufnr: number): Promise<void> {
if (!this.config.triggerAfterInsertEnter || this.config.autoTrigger !== 'always') return
if (!workspace.isAttached(bufnr)) return
let change = await this.nvim.call('coc#util#change_info') as InsertChange
change.pre = byteSlice(change.line, 0, change.col - 1)
if (!change.pre) return
let doc = workspace.getDocument(bufnr)
await this.triggerCompletion(doc, change)
}
public shouldTrigger(doc: Document, pre: string): boolean {
let { autoTrigger } = this.config
if (autoTrigger == 'none') return false
if (sources.shouldTrigger(pre, doc.filetype, doc.uri)) return true
if (autoTrigger !== 'always') return false
return true
}
private async onPumChange(): Promise<void> {
if (!this.popupEvent) return
let { col, row, height, width, scrollbar } = this.popupEvent
let bounding: PumBounding = { col, row, height, width, scrollbar }
let resolvedItem = this.getCompleteItem(this.selectedItem)
if (!resolvedItem) {
this.floating.close()
return
}
let source = this.resolveTokenSource = new CancellationTokenSource()
let { token } = source
await this.doCompleteResolve(resolvedItem, source)
if (token.isCancellationRequested) return
let docs = resolvedItem.documentation
if (!docs && resolvedItem.info) {
let { info } = resolvedItem
let isText = /^[\w-\s.,\t]+$/.test(info)
docs = [{ filetype: isText ? 'txt' : this.document.filetype, content: info }]
}
if (!this.config.floatEnable) return
if (!docs || docs.length == 0) {
this.floating.close()
} else {
let excludeImages = workspace.getConfiguration('coc.preferences').get<boolean>('excludeImageLinksInMarkdownDocument')
let config = Object.assign({}, this.config.floatConfig, { excludeImages })
await this.floating.show(docs, bounding, config)
}
}
public start(): void {
if (this.activated) return
this.activated = true
if (!this.config.keepCompleteopt) {
this.nvim.command(`noa set completeopt=${this.completeOpt}`, true)
}
}
private cancelResolve(): void {
if (this.resolveTokenSource) {
this.resolveTokenSource.cancel()
this.resolveTokenSource.dispose()
this.resolveTokenSource = null
}
}
public stop(): void {
events.completing = false
this.cancel()
if (this.activated) {
this.activated = false
let { nvim, config } = this
let completeOpt = config.keepCompleteopt ? '' : workspace.completeOpt
nvim.call('coc#_cancel', [1, completeOpt], true)
nvim.redrawVim()
}
}
public getInput(document: Document, pre: string): string {
let { asciiCharactersOnly } = this.config
let len = 0
for (let i = pre.length - 1; i >= 0; i--) {
let ch = pre[i]
let word = document.isWord(ch) && (asciiCharactersOnly ? ch.charCodeAt(0) < 255 : true)
if (word) {
len += 1
} else {
break
}
}
return len == 0 ? '' : pre.slice(-len)
}
private getPrependWord(document: Document, remain: string): string {
let idx = 0
for (let i = 0; i < remain.length; i++) {
if (document.isWord(remain[i])) {
idx = i + 1
} else {
break
}
}
return idx == 0 ? '' : remain.slice(0, idx)
}
public getResumeInput(): string {
let { option, pretext, document } = this
if (!option || !document) return null
let buf = Buffer.from(pretext, 'utf8')
if (buf.length < option.colnr - 1) return null
let pre = byteSlice(option.line, 0, option.colnr - 1)
if (!pretext.startsWith(pre)) return null
let remain = pretext.slice(pre.length)
if (remain.includes(' ')) return null
let input = buf.slice(option.col).toString('utf8')
if (input.length > 0 && option.blacklist && option.blacklist.includes(input)) return null
return input
}
private async filterResults(): Promise<void> {
let { complete } = this
let search = this.getResumeInput()
if (search == null) {
this.stop()
return
}
let items = await complete.filterResults(search)
// cancelled
if (items === undefined) return
if (items.length == 0) {
if (!complete.isCompleting) this.stop()
return
}
this.showCompletion(items)
}
private get completeOpt(): string {
let { noselect, enablePreview } = this.config
let preview = enablePreview && !workspace.env.pumevent ? ',preview' : ''
if (noselect) return `noselect,menuone${preview}`
return `noinsert,menuone${preview}`
}
private getCompleteItem(item: VimCompleteItem | {} | null): ExtendedCompleteItem | null {
if (!this.complete || !Is.vimCompleteItem(item)) return null
return this.complete.resolveCompletionItem(item)
}
private cancel(): void {
if (this.complete != null) {
this.complete.dispose()
this.complete = null
}
if (this.triggerTimer != null) {
clearTimeout(this.triggerTimer)
this.triggerTimer = null
}
this.cancelResolve()
this.previousItem = undefined
this.pretext = undefined
this.hasInsert = false
}
public dispose(): void {
this.cancelResolve()
disposeAll(this.disposables)
}
}
export default new Completion()