@krassowski/jupyterlab-lsp
Version:
Language Server Protocol integration for JupyterLab
543 lines • 23.5 kB
JavaScript
import { Dialog, showDialog } from '@jupyterlab/apputils';
import { nullTranslator } from '@jupyterlab/translation';
import { Signal } from '@lumino/signaling';
import mergeWith from 'lodash.mergewith';
import { EditorAdapter } from '../editor_integration/editor_adapter';
var createButton = Dialog.createButton;
export class StatusMessage {
constructor() {
this.message = '';
this.changed = new Signal(this);
this.timer = null;
}
/**
* Set the text message and (optionally) the timeout to remove it.
* @param message
* @param timeout - number of ms to until the message is cleaned;
* -1 if the message should stay up indefinitely;
* defaults to 3000ms (3 seconds)
*/
set(message, timeout = 1000 * 3) {
this.expire_timer();
this.message = message;
this.changed.emit();
if (timeout !== -1) {
this.timer = window.setTimeout(this.clear.bind(this), timeout);
}
}
clear() {
this.message = '';
this.changed.emit();
}
expire_timer() {
if (this.timer !== null) {
window.clearTimeout(this.timer);
this.timer = 0;
}
}
}
/**
* The values should follow the https://microsoft.github.io/language-server-protocol/specification guidelines
*/
const mime_type_language_map = {
'text/x-rsrc': 'r',
'text/x-r-source': 'r',
// currently there are no LSP servers for IPython we are aware of
'text/x-ipython': 'python'
};
/**
* Foreign code: low level adapter is not aware of the presence of foreign languages;
* it operates on the virtual document and must not attempt to infer the language dependencies
* as this would make the logic of inspections caching impossible to maintain, thus the WidgetAdapter
* has to handle that, keeping multiple connections and multiple virtual documents.
*/
export class WidgetAdapter {
// note: it could be using namespace/IOptions pattern,
// but I do not know how to make it work with the generic type T
// (other than using 'any' in the IOptions interface)
constructor(extension, widget) {
this.extension = extension;
this.widget = widget;
this.isDisposed = false;
this.app = extension.app;
this.connection_manager = extension.connection_manager;
this.adapterConnected = new Signal(this);
this.activeEditorChanged = new Signal(this);
this.editorRemoved = new Signal(this);
this.editorAdded = new Signal(this);
this.adapters = new Map();
this.status_message = new StatusMessage();
this.isConnected = false;
this.console = extension.console.scope('WidgetAdapter');
this.trans = (extension.translator || nullTranslator).load('jupyterlab-lsp');
// set up signal connections
this.widget.context.saveState.connect(this.on_save_state, this);
this.connection_manager.closed.connect(this.on_connection_closed, this);
this.widget.disposed.connect(this.dispose, this);
}
get foreign_code_extractors() {
return this.extension.foreign_code_extractors;
}
get code_overrides() {
return this.extension.code_overrides;
}
on_connection_closed(manager, { virtual_document }) {
var _a;
this.console.log('connection closed, disconnecting adapter', virtual_document.id_path);
if (virtual_document !== ((_a = this.virtual_editor) === null || _a === void 0 ? void 0 : _a.virtual_document)) {
return;
}
this.dispose();
}
dispose() {
var _a, _b;
if (this.isDisposed) {
return;
}
if ((_a = this.virtual_editor) === null || _a === void 0 ? void 0 : _a.virtual_document) {
this.disconnect_adapter((_b = this.virtual_editor) === null || _b === void 0 ? void 0 : _b.virtual_document);
}
this.widget.context.saveState.disconnect(this.on_save_state, this);
this.connection_manager.closed.disconnect(this.on_connection_closed, this);
this.widget.disposed.disconnect(this.dispose, this);
this.disconnect();
// just to be sure
this.virtual_editor = null;
this.app = null;
this.widget = null;
this.connection_manager = null;
this.widget = null;
this.isDisposed = true;
}
get widget_id() {
return this.widget.id;
}
get language() {
// the values should follow https://microsoft.github.io/language-server-protocol/specification guidelines,
// see the table in https://microsoft.github.io/language-server-protocol/specification#textDocumentItem
if (mime_type_language_map.hasOwnProperty(this.mime_type)) {
return mime_type_language_map[this.mime_type];
}
else {
let without_parameters = this.mime_type.split(';')[0];
let [type, subtype] = without_parameters.split('/');
if (type === 'application' || type === 'text') {
if (subtype.startsWith('x-')) {
return subtype.substr(2);
}
else {
return subtype;
}
}
else {
return this.mime_type;
}
}
}
disconnect() {
this.connection_manager.unregister_document(this.virtual_editor.virtual_document);
this.widget.context.model.contentChanged.disconnect(this.onContentChanged, this);
// pretend that all editors were removed to trigger the disconnection of even handlers
// they will be connected again on new connection
for (let editor of this.editors) {
this.editorRemoved.emit({
editor: editor
});
}
for (let adapter of this.adapters.values()) {
adapter.dispose();
}
this.adapters.clear();
this.virtual_editor.dispose();
}
// equivalent to triggering didClose and didOpen, as per syncing specification,
// but also reloads the connection; used during file rename (or when it was moved)
reload_connection() {
// ignore premature calls (before the editor was initialized)
if (this.virtual_editor == null) {
return;
}
// disconnect all existing connections (and dispose adapters)
this.disconnect();
// recreate virtual document using current path and language
// as virtual editor assumes it gets the virtual document at init,
// just dispose virtual editor (which disposes virtual document too)
// and re-initialize both virtual editor and document
this.init_virtual();
// reconnect
this.connect_document(this.virtual_editor.virtual_document, true).catch(this.console.warn);
}
on_save_state(context, state) {
// ignore premature calls (before the editor was initialized)
if (this.virtual_editor == null) {
return;
}
// TODO: remove workaround no later than with 3.2 release of JupyterLab
// workaround for https://github.com/jupyterlab/jupyterlab/issues/10721
// while already reverted in https://github.com/jupyterlab/jupyterlab/pull/10741,
// it was not released yet and many users will continue to run 3.1.0 and 3.1.1
// so lets workaround it for now
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const completedManually = state === 'completed manually';
if (state === 'completed' || completedManually) {
// note: must only be send to the appropriate connections as
// some servers (Julia) break if they receive save notification
// for a document that was not opened before, see:
// https://github.com/jupyter-lsp/jupyterlab-lsp/issues/490
const documents_to_save = [this.virtual_editor.virtual_document];
for (let virtual_document of documents_to_save) {
let connection = this.connection_manager.connections.get(virtual_document.uri);
this.console.log('Sending save notification for', virtual_document.uri, 'to', connection);
connection.sendSaved(virtual_document.document_info);
for (let foreign of virtual_document.foreign_documents.values()) {
documents_to_save.push(foreign);
}
}
}
}
/**
* public for use in tests (but otherwise could be private)
*/
update_documents() {
if (this.isDisposed) {
this.console.warn('Cannot update documents: adapter disposed');
return;
}
return this.virtual_editor.virtual_document.update_manager.update_documents(this.editors.map(ce_editor => {
return {
ce_editor: ce_editor,
value: this.virtual_editor.get_editor_value(ce_editor)
};
}));
}
get has_multiple_editors() {
return this.editors.length > 1;
}
async on_connected(data) {
let { virtual_document } = data;
this.connect_adapter(data.virtual_document, data.connection);
this.adapterConnected.emit(data);
this.isConnected = true;
const promise = this.update_documents();
if (!promise) {
this.console.warn('Could not update documents');
return;
}
await promise.then(() => {
// refresh the document on the LSP server
this.document_changed(virtual_document, virtual_document, true);
this.console.log('virtual document(s) for', this.document_path, 'have been initialized');
});
// Note: the logger extension behaves badly with non-default names
// as it changes the source to the active file afterwards anyways
const loggerSourceName = virtual_document.uri;
let log;
if (this.extension.user_console) {
const logger = this.extension.user_console.getLogger(loggerSourceName);
log = (text) => {
logger.log({
type: 'text',
data: text
});
};
}
else {
log = (text) => console.log(text);
}
data.connection.serverNotifications['$/logTrace'].connect((connection, message) => {
this.console.log(data.connection.serverIdentifier, 'trace', virtual_document.uri, message);
});
data.connection.serverNotifications['window/logMessage'].connect((connection, message) => {
this.console.log(data.connection.serverIdentifier, virtual_document.uri, message);
log(connection.serverIdentifier + ': ' + message.message);
});
data.connection.serverNotifications['window/showMessage'].connect((connection, message) => {
this.console.log(data.connection.serverIdentifier, virtual_document.uri, message.message);
void showDialog({
title: this.trans.__('Message from ') + connection.serverIdentifier,
body: message.message
});
});
data.connection.serverRequests['window/showMessageRequest'].setHandler(async (params) => {
this.console.log(data.connection.serverIdentifier, virtual_document.uri, params);
const actionItems = params.actions;
const buttons = actionItems
? actionItems.map(action => {
return createButton({
label: action.title
});
})
: [createButton({ label: this.trans.__('Dismiss') })];
const result = await showDialog({
title: this.trans.__('Message from ') + data.connection.serverIdentifier,
body: params.message,
buttons: buttons
});
const choice = buttons.indexOf(result.button);
if (choice === -1) {
return null;
}
if (actionItems) {
return actionItems[choice];
}
return null;
});
}
/**
* Opens a connection for the document. The connection may or may
* not be initialized, yet, and depending on when this is called, the client
* may not be fully connected.
*
* @param virtual_document a VirtualDocument
* @param send_open whether to open the document immediately
*/
async connect_document(virtual_document, send_open = false) {
virtual_document.changed.connect(this.document_changed, this);
virtual_document.foreign_document_opened.connect(this.on_foreign_document_opened, this);
const connection_context = await this.connect(virtual_document).catch(this.console.warn);
if (!send_open) {
return;
}
if (connection_context && connection_context.connection) {
connection_context.connection.sendOpenWhenReady(virtual_document.document_info);
}
else {
this.console.warn(`Connection for ${virtual_document.path} was not opened`);
}
}
create_virtual_editor(options) {
let editorType = this.extension.editor_type_manager.findBestImplementation(this.editors);
if (editorType == null) {
return null;
}
let virtualEditorConstructor = editorType.implementation;
return new virtualEditorConstructor(options);
}
init_virtual() {
let virtual_editor = this.create_virtual_editor({
adapter: this,
virtual_document: this.create_virtual_document()
});
if (virtual_editor == null) {
this.console.error('Could not initialize a VirtualEditor for adapter: ', this);
return;
}
this.virtual_editor = virtual_editor;
this.connect_contentChanged_signal();
}
/**
* Handler for opening a document contained in a parent document. The assumption
* is that the editor already exists for this, and as such the document
* should be queued for immediate opening.
*
* @param host the VirtualDocument that contains the VirtualDocument in another language
* @param context information about the foreign VirtualDocument
*/
async on_foreign_document_opened(host, context) {
const { foreign_document } = context;
await this.connect_document(foreign_document, true);
foreign_document.foreign_document_closed.connect(this.on_foreign_document_closed, this);
}
on_foreign_document_closed(host, context) {
const { foreign_document } = context;
foreign_document.foreign_document_closed.disconnect(this.on_foreign_document_closed, this);
foreign_document.foreign_document_opened.disconnect(this.on_foreign_document_opened, this);
foreign_document.changed.disconnect(this.document_changed, this);
}
document_changed(virtual_document, document, is_init = false) {
if (this.isDisposed) {
this.console.warn('Cannot swap document: adapter disposed');
return;
}
// TODO only send the difference, using connection.sendSelectiveChange()
let connection = this.connection_manager.connections.get(virtual_document.uri);
let adapter = this.adapters.get(virtual_document.id_path);
if (!(connection === null || connection === void 0 ? void 0 : connection.isReady)) {
this.console.log('Skipping document update signal: connection not ready');
return;
}
if (adapter == null) {
this.console.log('Skipping document update signal: adapter not ready');
return;
}
// this.virtual_editor.console.log(
// 'LSP: virtual document',
// virtual_document.id_path,
// 'has changed sending update'
// );
connection.sendFullTextChange(virtual_document.value, virtual_document.document_info);
// the first change (initial) is not propagated to features,
// as it has no associated CodeMirrorChange object
if (!is_init) {
// guarantee that the virtual editor won't perform an update of the virtual documents while
// the changes are recorded...
// TODO this is not ideal - why it solves the problem of some errors,
// it introduces an unnecessary delay. A better way could be to invalidate some of the updates when a new one comes in.
// but maybe not every one (then the outdated state could be kept for too long fo a user who writes very quickly)
// also we would not want to invalidate the updates for the purpose of autocompletion (the trigger characters)
this.virtual_editor.virtual_document.update_manager
.with_update_lock(async () => {
await adapter.updateAfterChange();
})
.then()
.catch(this.console.warn);
}
}
connect_adapter(virtual_document, connection, features = null) {
let adapter = this.create_adapter(virtual_document, connection, features);
this.adapters.set(virtual_document.id_path, adapter);
return adapter;
}
disconnect_adapter(virtual_document) {
let adapter = this.adapters.get(virtual_document.id_path);
this.adapters.delete(virtual_document.id_path);
if (adapter != null) {
adapter.dispose();
}
}
get_features(virtual_document) {
let adapter = this.adapters.get(virtual_document.id_path);
return adapter === null || adapter === void 0 ? void 0 : adapter.features;
}
async connect(virtual_document) {
let language = virtual_document.language;
this.console.log(`will connect using language: ${language}`);
let capabilities = {
textDocument: {
synchronization: {
dynamicRegistration: true,
willSave: false,
didSave: true,
willSaveWaitUntil: false
}
},
workspace: {
didChangeConfiguration: {
dynamicRegistration: true
}
}
};
for (const feature of this.extension.feature_manager.features) {
if (!feature.capabilities) {
continue;
}
capabilities = mergeWith(capabilities, feature.capabilities);
}
let options = {
capabilities,
virtual_document,
language,
document_path: this.document_path
};
let connection = await this.connection_manager.connect(options);
if (connection) {
await this.on_connected({ virtual_document, connection });
return {
connection,
virtual_document
};
}
else {
return undefined;
}
}
/**
* Connect the change signal in order to update all virtual documents after a change.
*
* Update to the state of a notebook may be done without a notice on the CodeMirror level,
* e.g. when a cell is deleted. Therefore a JupyterLab-specific signals are watched instead.
*
* While by not using the change event of CodeMirror editors we loose an easy way to send selective,
* (range) updates this can be still implemented by comparison of before/after states of the
* virtual documents, which is even more resilient and -obviously - editor-independent.
*/
connect_contentChanged_signal() {
this.widget.context.model.contentChanged.connect(this.onContentChanged, this);
}
create_adapter(virtual_document, connection, features = null) {
let adapter_features = new Array();
if (features === null) {
features = this.extension.feature_manager.features;
}
for (let feature of features) {
let featureEditorIntegrationConstructor = feature.editorIntegrationFactory.get(this.virtual_editor.editor_name);
let integration = new featureEditorIntegrationConstructor({
feature: feature,
virtual_editor: this.virtual_editor,
virtual_document: virtual_document,
connection: connection,
status_message: this.status_message,
settings: feature.settings,
adapter: this,
trans: this.trans
});
adapter_features.push(integration);
}
let adapter = new EditorAdapter(this.virtual_editor, virtual_document, adapter_features, this.console);
this.console.log('Adapter for', this.document_path, 'is ready.');
// the client is now fully ready: signal to the server that the document is "open"
connection.sendOpenWhenReady(virtual_document.document_info);
return adapter;
}
async onContentChanged(_slot) {
// update the virtual documents (sending the updates to LSP is out of scope here)
const promise = this.update_documents();
if (!promise) {
this.console.warn('Could not update documents');
return;
}
this.update_finished = promise.catch(this.console.warn);
await this.update_finished;
}
get_position_from_context_menu() {
// Note: could also try using this.app.contextMenu.menu.contentNode position.
// Note: could add a guard on this.app.contextMenu.menu.isAttached
// get the first node as it gives the most accurate approximation
let leaf_node = this.app.contextMenuHitTest(() => true);
if (!leaf_node) {
return null;
}
let { left, top } = leaf_node.getBoundingClientRect();
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
let event = this.app._contextMenuEvent;
// if possible, use more accurate position from the actual event
// (but this relies on an undocumented and unstable feature)
if (event !== undefined) {
left = event.clientX;
top = event.clientY;
event.stopPropagation();
}
return this.virtual_editor.window_coords_to_root_position({
left: left,
top: top
});
}
get_context(root_position) {
// ignore premature calls and calls after disposal
if (!this.virtual_editor) {
return null;
}
let document = this.virtual_editor.document_at_root_position(root_position);
let virtual_position = this.virtual_editor.root_position_to_virtual_position(root_position);
return {
document,
connection: this.connection_manager.connections.get(document.uri),
virtual_position,
root_position,
features: this.get_features(document),
editor: this.virtual_editor,
app: this.app,
adapter: this
};
}
get_context_from_context_menu() {
let root_position = this.get_position_from_context_menu();
if (!root_position) {
return null;
}
return this.get_context(root_position);
}
}
//# sourceMappingURL=adapter.js.map