atom-languageclient
Version:
Integrate Language Servers with Atom
446 lines (397 loc) • 15.7 kB
text/typescript
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() || "")
}
}