UNPKG

atom-languageclient

Version:
446 lines (397 loc) 15.7 kB
import Convert from "../convert" import { LanguageClientConnection, FileChangeType, TextDocumentSaveReason, TextDocumentSyncKind, TextDocumentSyncOptions, TextDocumentContentChangeEvent, VersionedTextDocumentIdentifier, ServerCapabilities, DidSaveTextDocumentParams, } from "../languageclient" import ApplyEditAdapter from "./apply-edit-adapter" import { CompositeDisposable, Disposable, TextEditor, BufferStoppedChangingEvent, TextChange } from "atom" import * as Utils from "../utils" /** * Public: Synchronizes the documents between Atom and the language server by notifying each end of changes, opening, * closing and other events as well as sending and applying changes either in whole or in part depending on what the * language server supports. */ export default class DocumentSyncAdapter { private _disposable = new CompositeDisposable() public _documentSync: TextDocumentSyncOptions private _editors: WeakMap<TextEditor, TextEditorSyncAdapter> = new WeakMap() private _versions: Map<string, number> = new Map() /** * Public: Determine whether this adapter can be used to adapt a language server based on the serverCapabilities * matrix textDocumentSync capability either being Full or Incremental. * * @param serverCapabilities The {ServerCapabilities} of the language server to consider. * @returns A {Boolean} indicating adapter can adapt the server based on the given serverCapabilities. */ public static canAdapt(serverCapabilities: ServerCapabilities): boolean { return this.canAdaptV2(serverCapabilities) || this.canAdaptV3(serverCapabilities) } private static canAdaptV2(serverCapabilities: ServerCapabilities): boolean { return ( serverCapabilities.textDocumentSync === TextDocumentSyncKind.Incremental || serverCapabilities.textDocumentSync === TextDocumentSyncKind.Full ) } private static canAdaptV3(serverCapabilities: ServerCapabilities): boolean { const options = serverCapabilities.textDocumentSync return ( options !== null && typeof options === "object" && (options.change === TextDocumentSyncKind.Incremental || options.change === TextDocumentSyncKind.Full) ) } /** * Public: Create a new {DocumentSyncAdapter} for the given language server. * * @param _connection A {LanguageClientConnection} to the language server to be kept in sync. * @param documentSync The document syncing options. * @param _editorSelector A predicate function that takes a {TextEditor} and returns a {boolean} indicating whether * this adapter should care about the contents of the editor. * @param _getLanguageIdFromEditor A function that returns a {string} of `languageId` used for `textDocument/didOpen` * notification. */ constructor( private _connection: LanguageClientConnection, private _editorSelector: (editor: TextEditor) => boolean, documentSync: TextDocumentSyncOptions | TextDocumentSyncKind | undefined, private _reportBusyWhile: Utils.ReportBusyWhile, private _getLanguageIdFromEditor: (editor: TextEditor) => string ) { if (typeof documentSync === "object") { this._documentSync = documentSync } else { this._documentSync = { change: documentSync || TextDocumentSyncKind.Full, } } this._disposable.add(atom.textEditors.observe(this.observeTextEditor.bind(this))) } /** Dispose this adapter ensuring any resources are freed and events unhooked. */ public dispose(): void { this._disposable.dispose() } /** * Examine a {TextEditor} and decide if we wish to observe it. If so ensure that we stop observing it when it is * closed or otherwise destroyed. * * @param editor A {TextEditor} to consider for observation. */ public observeTextEditor(editor: TextEditor): void { const listener = editor.observeGrammar((_grammar) => this._handleGrammarChange(editor)) this._disposable.add( editor.onDidDestroy(() => { this._disposable.remove(listener) listener.dispose() }) ) this._disposable.add(listener) if (!this._editors.has(editor) && this._editorSelector(editor)) { this._handleNewEditor(editor) } } private _handleGrammarChange(editor: TextEditor): void { const sync = this._editors.get(editor) if (sync != null && !this._editorSelector(editor)) { this._editors.delete(editor) this._disposable.remove(sync) sync.didClose() sync.dispose() } else if (sync == null && this._editorSelector(editor)) { this._handleNewEditor(editor) } } private _handleNewEditor(editor: TextEditor): void { const sync = new TextEditorSyncAdapter( editor, this._connection, this._documentSync, this._versions, this._reportBusyWhile, this._getLanguageIdFromEditor ) this._editors.set(editor, sync) this._disposable.add(sync) this._disposable.add( editor.onDidDestroy(() => { const destroyedSync = this._editors.get(editor) if (destroyedSync) { this._editors.delete(editor) this._disposable.remove(destroyedSync) destroyedSync.dispose() } }) ) } public getEditorSyncAdapter(editor: TextEditor): TextEditorSyncAdapter | undefined { return this._editors.get(editor) } } /** Public: Keep a single {TextEditor} in sync with a given language server. */ export class TextEditorSyncAdapter { private _disposable = new CompositeDisposable() private _currentUri: string private _fakeDidChangeWatchedFiles: boolean /** * Public: Create a {TextEditorSyncAdapter} in sync with a given language server. * * @param _editor A {TextEditor} to keep in sync. * @param _connection A {LanguageClientConnection} to a language server to keep in sync. * @param _documentSync The document syncing options. */ constructor( private _editor: TextEditor, private _connection: LanguageClientConnection, private _documentSync: TextDocumentSyncOptions, private _versions: Map<string, number>, private _reportBusyWhile: Utils.ReportBusyWhile, private _getLanguageIdFromEditor: (editor: TextEditor) => string ) { this._fakeDidChangeWatchedFiles = atom.project.onDidChangeFiles == null const changeTracking = this.setupChangeTracking(_documentSync) if (changeTracking != null) { this._disposable.add(changeTracking) } // These handlers are attached only if server supports them if (_documentSync.willSave) { this._disposable.add(_editor.getBuffer().onWillSave(this.willSave.bind(this))) } if (_documentSync.willSaveWaitUntil) { this._disposable.add(_editor.getBuffer().onWillSave(this.willSaveWaitUntil.bind(this))) } // Send close notifications unless it's explicitly disabled if (_documentSync.openClose !== false) { this._disposable.add(_editor.onDidDestroy(this.didClose.bind(this))) } this._disposable.add(_editor.onDidSave(this.didSave.bind(this)), _editor.onDidChangePath(this.didRename.bind(this))) this._currentUri = this.getEditorUri() if (_documentSync.openClose !== false) { this.didOpen() } } /** The change tracking disposable listener that will ensure that changes are sent to the language server as appropriate. */ public setupChangeTracking(documentSync: TextDocumentSyncOptions): Disposable | null { switch (documentSync.change) { case TextDocumentSyncKind.Full: return this._editor.onDidChange(this.sendFullChanges.bind(this)) case TextDocumentSyncKind.Incremental: return this._editor.getBuffer().onDidChangeText(this.sendIncrementalChanges.bind(this)) } return null } /** Dispose this adapter ensuring any resources are freed and events unhooked. */ public dispose(): void { this._disposable.dispose() } /** Get the languageId field that will be sent to the language server by simply using the `_getLanguageIdFromEditor`. */ public getLanguageId(): string { return this._getLanguageIdFromEditor(this._editor) } /** * Public: Create a {VersionedTextDocumentIdentifier} for the document observed by this adapter including both the Uri * and the current Version. */ public getVersionedTextDocumentIdentifier(): VersionedTextDocumentIdentifier { return { uri: this.getEditorUri(), version: this._getVersion(this._editor.getPath() || ""), } } /** Public: Send the entire document to the language server. This is used when operating in Full (1) sync mode. */ public sendFullChanges(): void { if (!this._isPrimaryAdapter()) { return } // Multiple editors, we are not first this._bumpVersion() this._connection.didChangeTextDocument({ textDocument: this.getVersionedTextDocumentIdentifier(), contentChanges: [{ text: this._editor.getText() }], }) } /** * Public: Send the incremental text changes to the language server. This is used when operating in Incremental (2) sync mode. * * @param event The event fired by Atom to indicate the document has stopped changing including a list of changes * since the last time this event fired for this text editor. NOTE: The order of changes in the event is guaranteed * top to bottom. Language server expects this in reverse. */ public sendIncrementalChanges(event: BufferStoppedChangingEvent): void { if (event.changes.length > 0) { if (!this._isPrimaryAdapter()) { return } // Multiple editors, we are not first this._bumpVersion() this._connection.didChangeTextDocument({ textDocument: this.getVersionedTextDocumentIdentifier(), contentChanges: event.changes.map(TextEditorSyncAdapter.textEditToContentChange).reverse(), }) } } /** * Public: Convert an Atom {TextEditEvent} to a language server {TextDocumentContentChangeEvent} object. * * @param change The Atom {TextEditEvent} to convert. * @returns A {TextDocumentContentChangeEvent} that represents the converted {TextEditEvent}. */ public static textEditToContentChange(change: TextChange): TextDocumentContentChangeEvent { return { range: Convert.atomRangeToLSRange(change.oldRange), rangeLength: change.oldText.length, text: change.newText, } } private _isPrimaryAdapter(): boolean { const lowestIdForBuffer = Math.min( ...atom.workspace .getTextEditors() .filter((t) => t.getBuffer() === this._editor.getBuffer()) .map((t) => t.id) ) return lowestIdForBuffer === this._editor.id } private _bumpVersion(): void { const filePath = this._editor.getPath() if (filePath == null) { return } this._versions.set(filePath, this._getVersion(filePath) + 1) } /** * Ensure when the document is opened we send notification to the language server so it can load it in and keep track * of diagnostics etc. */ private didOpen(): void { const filePath = this._editor.getPath() if (filePath == null) { return } // Not yet saved if (!this._isPrimaryAdapter()) { return } // Multiple editors, we are not first this._connection.didOpenTextDocument({ textDocument: { uri: this.getEditorUri(), languageId: this.getLanguageId().toLowerCase(), version: this._getVersion(filePath), text: this._editor.getText(), }, }) } private _getVersion(filePath: string): number { return this._versions.get(filePath) || 1 } /** Called when the {TextEditor} is closed and sends the 'didCloseTextDocument' notification to the connected language server. */ public didClose(): void { if (this._editor.getPath() == null) { return } // Not yet saved const fileStillOpen = atom.workspace.getTextEditors().find((t) => t.getBuffer() === this._editor.getBuffer()) if (fileStillOpen) { return // Other windows or editors still have this file open } this._connection.didCloseTextDocument({ textDocument: { uri: this.getEditorUri() } }) } /** Called just before the {TextEditor} saves and sends the 'willSaveTextDocument' notification to the connected language server. */ public willSave(): void { if (!this._isPrimaryAdapter()) { return } const uri = this.getEditorUri() this._connection.willSaveTextDocument({ textDocument: { uri }, reason: TextDocumentSaveReason.Manual, }) } /** * Called just before the {TextEditor} saves, sends the 'willSaveWaitUntilTextDocument' request to the connected * language server and waits for the response before saving the buffer. */ public async willSaveWaitUntil(): Promise<void> { if (!this._isPrimaryAdapter()) { return Promise.resolve() } const buffer = this._editor.getBuffer() const uri = this.getEditorUri() const title = this._editor.getLongTitle() const applyEditsOrTimeout = Utils.promiseWithTimeout( 2500, // 2.5 seconds timeout this._connection.willSaveWaitUntilTextDocument({ textDocument: { uri }, reason: TextDocumentSaveReason.Manual, }) ) .then((edits) => { const cursor = this._editor.getCursorBufferPosition() ApplyEditAdapter.applyEdits(buffer, Convert.convertLsTextEdits(edits)) this._editor.setCursorBufferPosition(cursor) }) .catch((err) => { atom.notifications.addError("On-save action failed", { description: `Failed to apply edits to ${title}`, detail: err.message, }) return }) const withBusySignal = this._reportBusyWhile(`Applying on-save edits for ${title}`, () => applyEditsOrTimeout) return withBusySignal || applyEditsOrTimeout } /** * Called when the {TextEditor} saves and sends the 'didSaveTextDocument' notification to the connected language * server. Note: Right now this also sends the `didChangeWatchedFiles` notification as well but that will be sent from * elsewhere soon. */ public didSave(): void { if (!this._isPrimaryAdapter()) { return } const uri = this.getEditorUri() const didSaveNotification = { textDocument: { uri, version: this._getVersion(uri) }, } as DidSaveTextDocumentParams if (typeof this._documentSync.save === "object" && this._documentSync.save.includeText) { didSaveNotification.text = this._editor.getText() } this._connection.didSaveTextDocument(didSaveNotification) if (this._fakeDidChangeWatchedFiles) { this._connection.didChangeWatchedFiles({ changes: [{ uri, type: FileChangeType.Changed }], }) } } public didRename(): void { if (!this._isPrimaryAdapter()) { return } const oldUri = this._currentUri this._currentUri = this.getEditorUri() if (!oldUri) { return // Didn't previously have a name } if (this._documentSync.openClose !== false) { this._connection.didCloseTextDocument({ textDocument: { uri: oldUri } }) } if (this._fakeDidChangeWatchedFiles) { this._connection.didChangeWatchedFiles({ changes: [ { uri: oldUri, type: FileChangeType.Deleted }, { uri: this._currentUri, type: FileChangeType.Created }, ], }) } // Send an equivalent open event for this editor, which will now use the new // file path. if (this._documentSync.openClose !== false) { this.didOpen() } } /** Public: Obtain the current {TextEditor} path and convert it to a Uri. */ public getEditorUri(): string { return Convert.pathToUri(this._editor.getPath() || "") } }