'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> = new Map() private buffers: Map = new Map() private winids: Set = new Set() private resolves: ((doc: Document) => void)[] = [] private readonly _onDidOpenTextDocument = new Emitter() private readonly _onDidCloseDocument = new Emitter() private readonly _onDidChangeDocument = new Emitter() private readonly _onDidSaveDocument = new Emitter() private readonly _onWillSaveDocument = new Emitter() public readonly onDidOpenTextDocument: Event = this._onDidOpenTextDocument.event public readonly onDidCloseDocument: Event = this._onDidCloseDocument.event public readonly onDidChangeDocument: Event = this._onDidChangeDocument.event public readonly onDidSaveTextDocument: Event = this._onDidSaveDocument.event public readonly onWillSaveTextDocument: Event = this._onWillSaveDocument.event constructor( private readonly configurations: Configurations, private readonly workspaceFolder: WorkspaceFolder, ) { this._cwd = process.cwd() } public async attach(nvim: Neovim, env: Env): Promise { if (this._attached) return this.nvim = nvim this._env = env this._attached = true let preferences = this.configurations.getConfiguration('coc.preferences') let maxFileSize = preferences.get('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 { if (this._currentResolve) { return new Promise(resolve => { this.resolves.push(resolve) }) } this._currentResolve = true return new Promise((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 { let res = new Set() for (let doc of this.documents) { res.add(doc.filetype) } return res } /** * Current languageIds. */ public get languageIds(): Set { let res = new Set() for (let doc of this.documents) { res.add(doc.languageId) } return res } /** * Get format options */ public async getFormatOptions(uri?: string): Promise { 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 { 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(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 { 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 { 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 { 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 { 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[] = [] let event: TextDocumentWillSaveEvent = { document: doc.textDocument, reason: TextDocumentSaveReason.Manual, waitUntil: (thenable: Thenable) => { 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(resolve => { const preferences = this.configurations.getConfiguration('coc.preferences') const willSaveHandlerTimeout = preferences.get('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> { let filesLines: { [fsPath: string]: string[] } = {} let filepathList = locations.reduce((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 { 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 { 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 { 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) } }