'use strict' import { Neovim } from '@chemzqm/neovim' import { CancellationTokenSource, Position, Range, TextEdit } from 'vscode-languageserver-protocol' import commandManager from '../commands' import events from '../events' import languages from '../languages' import Document from '../model/document' import snippetManager from '../snippets/manager' import { ConfigurationChangeEvent, HandlerDelegate } from '../types' import { isWord } from '../util/string' import window from '../window' import workspace from '../workspace' const logger = require('../util/logger')('handler-format') const pairs: Map = new Map([ ['<', '>'], ['>', '<'], ['{', '}'], ['[', ']'], ['(', ')'], ]) interface FormatPreferences { formatOnType: boolean formatOnTypeFiletypes: string[] formatOnSaveFiletypes: string[] bracketEnterImprove: boolean } export default class FormatHandler { private preferences: FormatPreferences constructor( private nvim: Neovim, private handler: HandlerDelegate ) { this.loadPreferences() handler.addDisposable(workspace.onDidChangeConfiguration(this.loadPreferences, this)) handler.addDisposable(workspace.onWillSaveTextDocument(event => { let { languageId } = event.document let filetypes = this.preferences.formatOnSaveFiletypes if (filetypes.includes(languageId) || filetypes.includes('*')) { let willSaveWaitUntil = async (): Promise => { if (!languages.hasFormatProvider(event.document)) { logger.warn(`Format provider not found for ${event.document.uri}`) return undefined } let options = await workspace.getFormatOptions(event.document.uri) let tokenSource = new CancellationTokenSource() let timer: NodeJS.Timer const tp = new Promise(c => { timer = setTimeout(() => { logger.warn(`Format on save ${event.document.uri} timeout after 0.5s`) tokenSource.cancel() c(undefined) }, 500) }) const provideEdits = languages.provideDocumentFormattingEdits(event.document, options, tokenSource.token) let textEdits = await Promise.race([tp, provideEdits]) clearTimeout(timer) return Array.isArray(textEdits) ? textEdits : undefined } event.waitUntil(willSaveWaitUntil()) } })) let enterTs: number let enterBufnr: number handler.addDisposable(events.on('Enter', async bufnr => { enterTs = Date.now() enterBufnr = bufnr })) handler.addDisposable(events.on('CursorMovedI', async bufnr => { if (bufnr == enterBufnr && Date.now() - enterTs < 100) { enterBufnr = undefined await this.handleEnter(bufnr) } })) handler.addDisposable(events.on('TextInsert', async (bufnr: number, info, character: string) => { if (!events.pumvisible) await this.tryFormatOnType(character, bufnr) })) handler.addDisposable(commandManager.registerCommand('editor.action.formatDocument', async (uri?: string | number) => { const doc = uri ? workspace.getDocument(uri) : (await this.handler.getCurrentState()).doc await this.documentFormat(doc) })) commandManager.titles.set('editor.action.formatDocument', 'Format Document') } private loadPreferences(e?: ConfigurationChangeEvent): void { if (!e || e.affectsConfiguration('coc.preferences')) { let config = workspace.getConfiguration('coc.preferences') this.preferences = { formatOnType: config.get('formatOnType', false), formatOnSaveFiletypes: config.get('formatOnSaveFiletypes', []), formatOnTypeFiletypes: config.get('formatOnTypeFiletypes', []), bracketEnterImprove: config.get('bracketEnterImprove', true), } } } private async tryFormatOnType(ch: string, bufnr: number, newLine = false): Promise { if (!ch || isWord(ch) || !this.preferences.formatOnType) return if (snippetManager.getSession(bufnr) != null) return let doc = workspace.getDocument(bufnr) if (!doc || !doc.attached || doc.isCommandLine) return const filetypes = this.preferences.formatOnTypeFiletypes if (filetypes.length && !filetypes.includes(doc.filetype) && !filetypes.includes('*')) { // Only check formatOnTypeFiletypes when set, avoid breaking change return } if (!languages.hasProvider('formatOnType', doc.textDocument)) { logger.warn(`Format on type provider not found for buffer: ${doc.uri}`) return } if (!languages.canFormatOnType(ch, doc.textDocument)) return let position: Position let edits = await this.handler.withRequestToken('Format on type', async token => { position = await window.getCursorPosition() let origLine = doc.getline(position.line - 1) // not format for empty line. if (newLine && /^\s*$/.test(origLine)) return await doc.synchronize() return await languages.provideDocumentOnTypeEdits(ch, doc.textDocument, position, token) }) if (!edits || !edits.length) return await doc.applyEdits(edits, false, true) } public async formatCurrentBuffer(): Promise { let { doc } = await this.handler.getCurrentState() return await this.documentFormat(doc) } public async formatCurrentRange(mode: string): Promise { let { doc } = await this.handler.getCurrentState() return await this.documentRangeFormat(doc, mode) } public async documentFormat(doc: Document): Promise { await doc.synchronize() if (!languages.hasFormatProvider(doc.textDocument)) { throw new Error(`Format provider not found for buffer: ${doc.bufnr}`) } let options = await workspace.getFormatOptions(doc.uri) let textEdits = await this.handler.withRequestToken('format', token => { return languages.provideDocumentFormattingEdits(doc.textDocument, options, token) }) if (textEdits && textEdits.length > 0) { await doc.applyEdits(textEdits, false, true) return true } return false } private async handleEnter(bufnr: number): Promise { let { nvim } = this let { bracketEnterImprove } = this.preferences await this.tryFormatOnType('\n', bufnr) if (bracketEnterImprove) { let line = (await nvim.call('line', '.') as number) - 1 let doc = workspace.getDocument(bufnr) if (!doc) return await doc.patchChange() let pre = doc.getline(line - 1) let curr = doc.getline(line) let prevChar = pre[pre.length - 1] if (prevChar && pairs.has(prevChar)) { let nextChar = curr.trim()[0] if (nextChar && pairs.get(prevChar) == nextChar) { let edits: TextEdit[] = [] let opts = await workspace.getFormatOptions(doc.uri) let space = opts.insertSpaces ? ' '.repeat(opts.tabSize) : '\t' let currIndent = curr.match(/^\s*/)[0] let pos: Position = Position.create(line - 1, pre.length) // make sure indent of current line if (doc.filetype == 'vim') { let newText = '\n' + currIndent + space edits.push({ range: Range.create(line, currIndent.length, line, currIndent.length), newText: ' \\ ' }) newText = newText + '\\ ' edits.push({ range: Range.create(pos, pos), newText }) await doc.applyEdits(edits) await window.moveTo(Position.create(line, newText.length - 1)) } else { await nvim.eval(`feedkeys("\\O", 'in')`) } } } } } public async documentRangeFormat(doc: Document, mode?: string): Promise { this.handler.checkProvier('formatRange', doc.textDocument) await doc.synchronize() let range: Range if (mode) { range = await window.getSelectedRange(mode) if (!range) return -1 } else { let [lnum, count, mode] = await this.nvim.eval("[v:lnum,v:count,mode()]") as [number, number, string] // we can't handle if (count == 0 || mode == 'i' || mode == 'R') return -1 range = Range.create(lnum - 1, 0, lnum - 1 + count, 0) } let options = await workspace.getFormatOptions(doc.uri) let textEdits = await this.handler.withRequestToken('Format range', token => { return languages.provideDocumentRangeFormattingEdits(doc.textDocument, range, options, token) }) if (textEdits && textEdits.length > 0) { await doc.applyEdits(textEdits, false, true) return 0 } return -1 } }