1
0
Fork 0
mirror of synced 2024-06-25 18:21:11 -04:00
ultimate-vim/sources_non_forked/coc.nvim/src/core/documents.ts
2022-07-20 13:20:15 +08:00

593 lines
20 KiB
TypeScript

'use strict'
import { Neovim } from '@chemzqm/neovim'
import bytes from 'bytes'
import fs from 'fs'
import os from 'os'
import path from 'path'
import { Disposable, Emitter, Event, FormattingOptions, Location, LocationLink, TextDocumentSaveReason, TextEdit } from 'vscode-languageserver-protocol'
import { URI } from 'vscode-uri'
import Configurations from '../configuration'
import events, { InsertChange } from '../events'
import Document from '../model/document'
import { LinesTextDocument } from '../model/textdocument'
import { BufferOption, DidChangeTextDocumentParams, Env, QuickfixItem, TextDocumentWillSaveEvent } from '../types'
import { disposeAll, platform } from '../util'
import { readFileLine } from '../util/fs'
import { byteIndex } from '../util/string'
import WorkspaceFolder from './workspaceFolder'
const logger = require('../util/logger')('core-documents')
interface StateInfo {
bufnr: number
winid: number
bufnrs: number[]
winids: number[]
}
export default class Documents implements Disposable {
private _cwd: string
private _env: Env
private _bufnr: number
private _root: string
private _initialized = false
private _attached = false
private _currentResolve = false
private nvim: Neovim
private maxFileSize: number
private disposables: Disposable[] = []
private creating: Map<number, Promise<Document | undefined>> = new Map()
private buffers: Map<number, Document> = new Map()
private winids: Set<number> = new Set()
private resolves: ((doc: Document) => void)[] = []
private readonly _onDidOpenTextDocument = new Emitter<LinesTextDocument>()
private readonly _onDidCloseDocument = new Emitter<LinesTextDocument>()
private readonly _onDidChangeDocument = new Emitter<DidChangeTextDocumentParams>()
private readonly _onDidSaveDocument = new Emitter<LinesTextDocument>()
private readonly _onWillSaveDocument = new Emitter<TextDocumentWillSaveEvent>()
public readonly onDidOpenTextDocument: Event<LinesTextDocument> = this._onDidOpenTextDocument.event
public readonly onDidCloseDocument: Event<LinesTextDocument> = this._onDidCloseDocument.event
public readonly onDidChangeDocument: Event<DidChangeTextDocumentParams> = this._onDidChangeDocument.event
public readonly onDidSaveTextDocument: Event<LinesTextDocument> = this._onDidSaveDocument.event
public readonly onWillSaveTextDocument: Event<TextDocumentWillSaveEvent> = this._onWillSaveDocument.event
constructor(
private readonly configurations: Configurations,
private readonly workspaceFolder: WorkspaceFolder,
) {
this._cwd = process.cwd()
}
public async attach(nvim: Neovim, env: Env): Promise<void> {
if (this._attached) return
this.nvim = nvim
this._env = env
this._attached = true
let preferences = this.configurations.getConfiguration('coc.preferences')
let maxFileSize = preferences.get<string>('maxFileSize', '10MB')
this.maxFileSize = bytes.parse(maxFileSize)
nvim.setVar('coc_max_filesize', this.maxFileSize, true)
let { bufnrs, winid, bufnr, winids } = await this.nvim.call('coc#util#all_state') as StateInfo
this.winids = new Set(winids)
this._bufnr = bufnr
await Promise.all(bufnrs.map(bufnr => this.createDocument(bufnr)))
events.on('BufDetach', this.onBufDetach, this, this.disposables)
events.on('VimLeavePre', () => {
this.resolveCurrent(undefined)
}, null, this.disposables)
events.on('WinEnter', (winid: number) => {
this.winids.add(winid)
}, null, this.disposables)
events.on('BufWinEnter', (_, winid: number) => {
this.winids.add(winid)
}, null, this.disposables)
events.on('DirChanged', cwd => {
this._cwd = cwd
}, null, this.disposables)
// check unloaded buffers
events.on('CursorHold', async () => {
let { bufnrs, winids } = await this.nvim.call('coc#util#all_state') as StateInfo
for (let bufnr of this.buffers.keys()) {
if (!bufnrs.includes(bufnr)) void events.fire('BufUnload', [bufnr])
}
for (let winid of this.winids) {
if (!winids.includes(winid)) void events.fire('WinClosed', [winid])
}
this.winids = new Set(winids)
}, null, this.disposables)
const checkCurrentBuffer = (bufnr: number) => {
this._bufnr = bufnr
void this.createDocument(bufnr)
}
events.on('CursorMoved', checkCurrentBuffer, null, this.disposables)
events.on('CursorMovedI', checkCurrentBuffer, null, this.disposables)
events.on('BufUnload', this.onBufUnload, this, this.disposables)
events.on('BufEnter', this.onBufEnter, this, this.disposables)
events.on('BufCreate', this.onBufCreate, this, this.disposables)
events.on('TermOpen', this.onBufCreate, this, this.disposables)
events.on('BufWritePost', this.onBufWritePost, this, this.disposables)
events.on('BufWritePre', this.onBufWritePre, this, this.disposables)
events.on('FileType', this.onFileTypeChange, this, this.disposables)
void events.fire('BufEnter', [bufnr])
void events.fire('BufWinEnter', [bufnr, winid])
events.on('BufEnter', (bufnr: number) => {
void this.createDocument(bufnr)
}, null, this.disposables)
events.on('CursorHold', (bufnr: number, _, variables) => {
let buf = this.getDocument(bufnr)
if (buf) buf.onCursorHold(variables)
}, null, this.disposables)
if (this._env.isVim) {
['TextChangedP', 'TextChangedI', 'TextChanged'].forEach(event => {
events.on(event as any, (bufnr: number, info?: InsertChange) => {
let doc = this.buffers.get(bufnr)
if (doc?.attached) doc.onTextChange(event, info)
}, null, this.disposables)
})
} else {
events.on('CompleteDone', async () => {
let ev = await events.race(['TextChangedI', 'TextChanged', 'MenuPopupChanged'], 100)
if (ev && (ev.name === 'TextChangedI' || ev.name === 'TextChanged')) {
let doc = this.buffers.get(events.bufnr)
if (doc?.attached) doc._forceSync()
}
}, null, this.disposables)
}
this._initialized = true
}
public get bufnr(): number {
return this._bufnr
}
public get root(): string {
return this._root
}
public get cwd(): string {
return this._cwd
}
public get documents(): Document[] {
return Array.from(this.buffers.values()).filter(o => o.attached && !o.isCommandLine)
}
public get bufnrs(): number[] {
return Array.from(this.buffers.keys())
}
public detach(): void {
if (!this._attached) return
this._attached = false
for (let bufnr of this.buffers.keys()) {
this.onBufUnload(bufnr)
}
disposeAll(this.disposables)
}
public get textDocuments(): LinesTextDocument[] {
let docs: LinesTextDocument[] = []
for (let b of this.buffers.values()) {
if (b.attached) docs.push(b.textDocument)
}
return docs
}
public getDocument(uri: number | string): Document | null {
if (typeof uri === 'number') {
return this.buffers.get(uri)
}
const caseInsensitive = platform.isWindows || platform.isMacintosh
uri = URI.parse(uri).toString()
for (let doc of this.buffers.values()) {
if (doc.uri === uri) return doc
if (caseInsensitive && doc.uri.toLowerCase() === uri.toLowerCase()) return doc
}
return null
}
/**
* Expand filepath with `~` and/or environment placeholders
*/
public expand(input: string): string {
if (input.startsWith('~')) {
input = os.homedir() + input.slice(1)
}
if (input.includes('$')) {
let doc = this.getDocument(this.bufnr)
let fsPath = doc ? URI.parse(doc.uri).fsPath : ''
input = input.replace(/\$\{(.*?)\}/g, (match: string, name: string) => {
if (name.startsWith('env:')) {
let key = name.split(':')[1]
let val = key ? process.env[key] : ''
return val
}
switch (name) {
case 'workspace':
case 'workspaceRoot':
case 'workspaceFolder':
return this._root
case 'workspaceFolderBasename':
return path.dirname(this._root)
case 'cwd':
return this._cwd
case 'file':
return fsPath
case 'fileDirname':
return fsPath ? path.dirname(fsPath) : ''
case 'fileExtname':
return fsPath ? path.extname(fsPath) : ''
case 'fileBasename':
return fsPath ? path.basename(fsPath) : ''
case 'fileBasenameNoExtension': {
let basename = fsPath ? path.basename(fsPath) : ''
return basename ? basename.slice(0, basename.length - path.extname(basename).length) : ''
}
default:
return match
}
})
input = input.replace(/\$[\w]+/g, match => {
if (match == '$HOME') return os.homedir()
return process.env[match.slice(1)] || match
})
}
return input
}
/**
* Current document.
*/
public get document(): Promise<Document | undefined> {
if (this._currentResolve) {
return new Promise<Document>(resolve => {
this.resolves.push(resolve)
})
}
this._currentResolve = true
return new Promise<Document>((resolve, reject) => {
this.nvim.eval('coc#util#get_bufoptions(bufnr("%"))').then((opts: BufferOption) => {
let doc: Document | undefined
if (opts != null) {
this.creating.delete(opts.bufnr)
doc = this._createDocument(opts)
}
this.resolveCurrent(doc)
resolve(doc)
this._currentResolve = false
}, reject)
})
}
private resolveCurrent(document: Document | undefined): void {
if (this.resolves.length > 0) {
while (this.resolves.length) {
const fn = this.resolves.pop()
if (fn) fn(document)
}
}
}
public get uri(): string {
let { bufnr } = this
if (bufnr) {
let doc = this.getDocument(bufnr)
if (doc) return doc.uri
}
return null
}
/**
* Current filetypes.
*/
public get filetypes(): Set<string> {
let res = new Set<string>()
for (let doc of this.documents) {
res.add(doc.filetype)
}
return res
}
/**
* Current languageIds.
*/
public get languageIds(): Set<string> {
let res = new Set<string>()
for (let doc of this.documents) {
res.add(doc.languageId)
}
return res
}
/**
* Get format options
*/
public async getFormatOptions(uri?: string): Promise<FormattingOptions> {
let doc: Document
if (uri) doc = this.getDocument(uri)
let bufnr = doc ? doc.bufnr : 0
let res = await this.nvim.call('coc#util#get_format_opts', [bufnr]) as any
let obj: FormattingOptions = { tabSize: res.tabsize, insertSpaces: res.expandtab == 1 }
obj.insertFinalNewline = res.insertFinalNewline == 1
if (res.trimTrailingWhitespace) obj.trimTrailingWhitespace = true
if (res.trimFinalNewlines) obj.trimFinalNewlines = true
return obj
}
/**
* Create document by bufnr.
*/
public async createDocument(bufnr: number): Promise<Document | undefined> {
let doc = this.buffers.get(bufnr)
if (doc) return doc
if (this.creating.has(bufnr)) return await this.creating.get(bufnr)
let promise = new Promise<Document | undefined>(resolve => {
this.nvim.call('coc#util#get_bufoptions', [bufnr]).then(opts => {
if (!this.creating.has(bufnr)) {
resolve(undefined)
return
}
this.creating.delete(bufnr)
if (!opts) {
resolve(undefined)
return
}
doc = this._createDocument(opts)
resolve(doc)
}, () => {
this.creating.delete(bufnr)
resolve(undefined)
})
})
this.creating.set(bufnr, promise)
return await promise
}
public async onBufCreate(bufnr: number): Promise<void> {
this.onBufUnload(bufnr)
await this.createDocument(bufnr)
}
private _createDocument(opts: BufferOption): Document {
let { bufnr } = opts
if (this.buffers.has(bufnr)) return this.buffers.get(bufnr)
let buffer = this.nvim.createBuffer(bufnr)
let doc = new Document(buffer, this._env, this.nvim, opts)
this.buffers.set(bufnr, doc)
if (doc.attached) {
if (doc.schema == 'file') {
let configfile = this.configurations.resolveFolderConfigution(doc.uri)
let root = this.workspaceFolder.resolveRoot(doc, this._cwd, this._initialized, this.expand.bind(this))
if (bufnr == this._bufnr) {
if (configfile) this.configurations.setFolderConfiguration(doc.uri)
if (root) this._root = root
}
}
this._onDidOpenTextDocument.fire(doc.textDocument)
doc.onDocumentChange(e => this._onDidChangeDocument.fire(e))
}
logger.debug('buffer created', bufnr, doc.attached, doc.uri)
return doc
}
private onBufEnter(bufnr: number): void {
this._bufnr = bufnr
let doc = this.buffers.get(bufnr)
if (doc) {
this.configurations.setFolderConfiguration(doc.uri)
let workspaceFolder = this.workspaceFolder.getWorkspaceFolder(URI.parse(doc.uri))
if (workspaceFolder) this._root = URI.parse(workspaceFolder.uri).fsPath
}
}
private onBufUnload(bufnr: number): void {
this.creating.delete(bufnr)
void this.onBufDetach(bufnr, false)
}
private async onBufDetach(bufnr: number, checkReload = true): Promise<void> {
this.detachBuffer(bufnr)
if (checkReload) {
let loaded = await this.nvim.call('bufloaded', [bufnr])
if (loaded) await this.createDocument(bufnr)
}
}
private detachBuffer(bufnr: number): void {
let doc = this.buffers.get(bufnr)
if (!doc) return
logger.debug('document detach', bufnr, doc.uri)
this._onDidCloseDocument.fire(doc.textDocument)
this.buffers.delete(bufnr)
doc.detach()
}
private async onBufWritePost(bufnr: number, changedtick: number): Promise<void> {
let doc = this.buffers.get(bufnr)
if (doc) {
if (doc.changedtick != changedtick) await doc.patchChange()
this._onDidSaveDocument.fire(doc.textDocument)
}
}
private async onBufWritePre(bufnr: number, bufname: string, changedtick: number): Promise<void> {
let doc = this.buffers.get(bufnr)
if (!doc || !doc.attached) return
if (doc.bufname != bufname) {
this.detachBuffer(bufnr)
doc = await this.createDocument(bufnr)
if (!doc.attached) return
}
if (doc.changedtick != changedtick) {
await doc.synchronize()
} else {
await doc.patchChange()
}
let firing = true
let thenables: Thenable<TextEdit[] | any>[] = []
let event: TextDocumentWillSaveEvent = {
document: doc.textDocument,
reason: TextDocumentSaveReason.Manual,
waitUntil: (thenable: Thenable<any>) => {
if (!firing) {
logger.error(`Can't call waitUntil in async manner:`, Error().stack)
this.nvim.echoError(`waitUntil can't be used in async manner, check log for details`)
} else {
thenables.push(thenable)
}
}
}
this._onWillSaveDocument.fire(event)
firing = false
let total = thenables.length
if (total) {
let promise = new Promise<TextEdit[] | undefined>(resolve => {
const preferences = this.configurations.getConfiguration('coc.preferences')
const willSaveHandlerTimeout = preferences.get<number>('willSaveHandlerTimeout', 500)
let timer = setTimeout(() => {
this.nvim.outWriteLine(`Will save handler timeout after ${willSaveHandlerTimeout}ms`)
resolve(undefined)
}, willSaveHandlerTimeout)
let i = 0
let called = false
for (let p of thenables) {
let cb = (res: any) => {
if (called) return
called = true
clearTimeout(timer)
resolve(res)
}
p.then(res => {
if (Array.isArray(res) && res.length && TextEdit.is(res[0])) {
return cb(res)
}
i = i + 1
if (i == total) cb(undefined)
}, e => {
logger.error(`Error on will save handler:`, e)
i = i + 1
if (i == total) cb(undefined)
})
}
})
let edits = await promise
if (edits) await doc.applyEdits(edits, false, this.bufnr === doc.bufnr)
}
}
private onFileTypeChange(filetype: string, bufnr: number): void {
let doc = this.getDocument(bufnr)
if (!doc) return
let converted = doc.convertFiletype(filetype)
if (converted == doc.filetype) return
this._onDidCloseDocument.fire(doc.textDocument)
doc.setFiletype(filetype)
this._onDidOpenTextDocument.fire(doc.textDocument)
}
public async getQuickfixList(locations: Location[]): Promise<ReadonlyArray<QuickfixItem>> {
let filesLines: { [fsPath: string]: string[] } = {}
let filepathList = locations.reduce<string[]>((pre: string[], curr) => {
let u = URI.parse(curr.uri)
if (u.scheme == 'file' && !pre.includes(u.fsPath) && !this.getDocument(curr.uri)) {
pre.push(u.fsPath)
}
return pre
}, [])
await Promise.all(filepathList.map(fsPath => {
return new Promise(resolve => {
fs.readFile(fsPath, 'utf8', (err, content) => {
if (err) return resolve(undefined)
filesLines[fsPath] = content.split(/\r?\n/)
resolve(undefined)
})
})
}))
return await Promise.all(locations.map(loc => {
let { uri, range } = loc
let { fsPath } = URI.parse(uri)
let text: string | undefined
let lines = filesLines[fsPath]
if (lines) text = lines[range.start.line]
return this.getQuickfixItem(loc, text)
}))
}
/**
* Convert location to quickfix item.
*/
public async getQuickfixItem(loc: Location | LocationLink, text?: string, type = '', module?: string): Promise<QuickfixItem> {
if (LocationLink.is(loc)) {
loc = Location.create(loc.targetUri, loc.targetRange)
}
let doc = this.getDocument(loc.uri)
let { uri, range } = loc
let { start, end } = range
let u = URI.parse(uri)
if (!text && u.scheme == 'file') {
text = await this.getLine(uri, start.line)
}
let endLine = start.line == end.line ? text : await this.getLine(uri, end.line)
let item: QuickfixItem = {
uri,
filename: u.scheme == 'file' ? u.fsPath : uri,
lnum: start.line + 1,
end_lnum: end.line + 1,
col: text ? byteIndex(text, start.character) + 1 : start.character + 1,
end_col: endLine ? byteIndex(endLine, end.character) + 1 : end.character + 1,
text: text || '',
range
}
if (module) item.module = module
if (type) item.type = type
if (doc) item.bufnr = doc.bufnr
return item
}
/**
* Get content of line by uri and line.
*/
public async getLine(uri: string, line: number): Promise<string> {
let document = this.getDocument(uri)
if (document && document.attached) return document.getline(line) || ''
if (!uri.startsWith('file:')) return ''
let fsPath = URI.parse(uri).fsPath
if (!fs.existsSync(fsPath)) return ''
return await readFileLine(fsPath, line)
}
/**
* Get content from buffer or file by uri.
*/
public async readFile(uri: string): Promise<string> {
let document = this.getDocument(uri)
if (document) {
await document.patchChange()
return document.content
}
let u = URI.parse(uri)
if (u.scheme != 'file') return ''
let lines = await this.nvim.call('readfile', [u.fsPath]) as string[]
return lines.join('\n') + '\n'
}
public reset(): void {
this.creating.clear()
for (let bufnr of this.buffers.keys()) {
this.onBufUnload(bufnr)
}
this.buffers.clear()
this._root = process.cwd()
}
public dispose(): void {
for (let bufnr of this.buffers.keys()) {
this.onBufUnload(bufnr)
}
this._attached = false
this.buffers.clear()
disposeAll(this.disposables)
}
}