@krassowski/jupyterlab-lsp
Version:
Language Server Protocol integration for JupyterLab
473 lines • 21.4 kB
JavaScript
import { CompletionConnector, CompletionHandler, ContextConnector, KernelConnector } from '@jupyterlab/completer';
import { KernelKind } from '@krassowski/completion-theme/lib/types';
import { PositionConverter } from '../../converter';
import { AdditionalCompletionTriggerKinds, CompletionItemKind, CompletionTriggerKind } from '../../lsp';
import { LazyCompletionItem } from './item';
var ICompletionItemsResponseType = CompletionHandler.ICompletionItemsResponseType;
export function transformLSPCompletions(token, position_in_token, lspCompletionItems, createCompletionItem, console) {
let prefix = token.value.slice(0, position_in_token + 1);
let all_non_prefixed = true;
let items = [];
lspCompletionItems.forEach(match => {
let kind = match.kind ? CompletionItemKind[match.kind] : '';
// Update prefix values
let text = match.insertText ? match.insertText : match.label;
// declare prefix presence if needed and update it
if (text.toLowerCase().startsWith(prefix.toLowerCase())) {
all_non_prefixed = false;
if (prefix !== token.value) {
if (text.toLowerCase().startsWith(token.value.toLowerCase())) {
// given a completion insert text "display_table" and two test cases:
// disp<tab>data → display_table<cursor>data
// disp<tab>lay → display_table<cursor>
// we have to adjust the prefix for the latter (otherwise we would get display_table<cursor>lay),
// as we are constrained NOT to replace after the prefix (which would be "disp" otherwise)
prefix = token.value;
}
}
}
// add prefix if needed
else if (token.type === 'string' && prefix.includes('/')) {
// special case for path completion in strings, ensuring that:
// '/Com<tab> → '/Completion.ipynb
// when the returned insert text is `Completion.ipynb` (the token here is `'/Com`)
// developed against pyls and pylsp server, may not work well in other cases
const parts = prefix.split('/');
if (text.toLowerCase().startsWith(parts[parts.length - 1].toLowerCase())) {
let pathPrefix = parts.slice(0, -1).join('/') + '/';
match.insertText = pathPrefix + text;
// for label removing the prefix quote if present
if (pathPrefix.startsWith("'") || pathPrefix.startsWith('"')) {
pathPrefix = pathPrefix.substr(1);
}
match.label = pathPrefix + match.label;
all_non_prefixed = false;
}
}
let completionItem = createCompletionItem(kind, match);
items.push(completionItem);
});
console.debug('Transformed');
// required to make the repetitive trigger characters like :: or ::: work for R with R languageserver,
// see https://github.com/jupyter-lsp/jupyterlab-lsp/issues/436
let prefix_offset = token.value.length;
// completion of dictionaries for Python with jedi-language-server was
// causing an issue for dic['<tab>'] case; to avoid this let's make
// sure that prefix.length >= prefix.offset
if (all_non_prefixed && prefix_offset > prefix.length) {
prefix_offset = prefix.length;
}
let response = {
// note in the ContextCompleter it was:
// start: token.offset,
// end: token.offset + token.value.length,
// which does not work with "from statistics import <tab>" as the last token ends at "t" of "import",
// so the completer would append "mean" as "from statistics importmean" (without space!);
// (in such a case the typedCharacters is undefined as we are out of range)
// a different workaround would be to prepend the token.value prefix:
// text = token.value + text;
// but it did not work for "from statistics <tab>" and lead to "from statisticsimport" (no space)
start: token.offset + (all_non_prefixed ? prefix_offset : 0),
end: token.offset + prefix.length,
items: items,
source: {
name: 'LSP',
priority: 2
}
};
if (response.start > response.end) {
console.warn('Response contains start beyond end; this should not happen!', response);
}
return response;
}
/**
* A LSP connector for completion handlers.
*/
export class LSPConnector {
/**
* Create a new LSP connector for completion requests.
*
* @param options - The instantiation options for the LSP connector.
*/
constructor(options) {
this.options = options;
this.isDisposed = false;
// signal that this is the new type connector (providing completion items)
this.responseType = ICompletionItemsResponseType;
this._editor = options.editor;
this._connections = options.connections;
this.virtual_editor = options.virtual_editor;
this._context_connector = new ContextConnector({ editor: options.editor });
if (options.session) {
let kernel_options = { editor: options.editor, session: options.session };
this._kernel_connector = new KernelConnector(kernel_options);
this._kernel_and_context_connector = new CompletionConnector(kernel_options);
}
this.lab_integration = options.labIntegration;
this.console = options.console;
}
get kernel_completions_first() {
return this.options.settings.composite.kernelCompletionsFirst;
}
get use_lsp_completions() {
return (this.options.settings.composite.disableCompletionsFrom.indexOf('LSP') ==
-1);
}
get use_kernel_completions() {
return (this.options.settings.composite.disableCompletionsFrom.indexOf('Kernel') == -1);
}
get suppress_continuous_hinting_in() {
return this.options.settings.composite.suppressContinuousHintingIn;
}
get suppress_trigger_character_in() {
return this.options.settings.composite.suppressTriggerCharacterIn;
}
get should_show_documentation() {
return this.options.settings.composite.showDocumentation;
}
dispose() {
if (this.isDisposed) {
return;
}
this._connections = null;
this.virtual_editor = null;
this._context_connector = null;
this._kernel_connector = null;
this._kernel_and_context_connector = null;
this.options = null;
this._editor = null;
this.isDisposed = true;
}
get _has_kernel() {
var _a;
return ((_a = this.options.session) === null || _a === void 0 ? void 0 : _a.kernel) != null;
}
get _is_kernel_idle() {
var _a, _b;
return ((_b = (_a = this.options.session) === null || _a === void 0 ? void 0 : _a.kernel) === null || _b === void 0 ? void 0 : _b.status) == 'idle';
}
get _should_wait_for_busy_kernel() {
return this.lab_integration.settings.composite.waitForBusyKernel;
}
async _kernel_language() {
return (await this.options.session.kernel.info).language_info.name;
}
get _kernel_timeout() {
return this.lab_integration.settings.composite.kernelResponseTimeout;
}
get fallback_connector() {
return this._kernel_and_context_connector
? this._kernel_and_context_connector
: this._context_connector;
}
transform_from_editor_to_root(position) {
let editor_position = PositionConverter.ce_to_cm(position);
return this.virtual_editor.transform_from_editor_to_root(this._editor, editor_position);
}
/**
* Fetch completion requests.
*
* @param request - The completion request text and details.
*/
async fetch(request) {
let editor = this._editor;
const cursor = editor.getCursorPosition();
const token = editor.getTokenForPosition(cursor);
if (this.trigger_kind == AdditionalCompletionTriggerKinds.AutoInvoked) {
if (token.type &&
this.suppress_continuous_hinting_in.indexOf(token.type) !== -1) {
this.console.debug('Suppressing completer auto-invoke in', token.type);
this.trigger_kind = CompletionTriggerKind.Invoked;
return;
}
}
else if (this.trigger_kind == CompletionTriggerKind.TriggerCharacter) {
if (token.type &&
this.suppress_trigger_character_in.indexOf(token.type) !== -1) {
this.console.debug('Suppressing completer auto-invoke in', token.type);
this.trigger_kind = CompletionTriggerKind.Invoked;
return;
}
}
const start = editor.getPositionAt(token.offset);
const end = editor.getPositionAt(token.offset + token.value.length);
let position_in_token = cursor.column - start.column - 1;
const typed_character = token.value[cursor.column - start.column - 1];
let start_in_root = this.transform_from_editor_to_root(start);
let end_in_root = this.transform_from_editor_to_root(end);
let cursor_in_root = this.transform_from_editor_to_root(cursor);
let virtual_editor = this.virtual_editor;
// find document for position
let document = virtual_editor.document_at_root_position(start_in_root);
let virtual_start = virtual_editor.root_position_to_virtual_position(start_in_root);
let virtual_end = virtual_editor.root_position_to_virtual_position(end_in_root);
let virtual_cursor = virtual_editor.root_position_to_virtual_position(cursor_in_root);
const lsp_promise = this.use_lsp_completions
? this.fetch_lsp(token, typed_character, virtual_start, virtual_end, virtual_cursor, document, position_in_token)
: Promise.resolve(undefined);
let promise = null;
try {
const kernelTimeout = this._kernel_timeout;
if (this.use_kernel_completions &&
this._kernel_connector &&
this._has_kernel &&
(this._is_kernel_idle || this._should_wait_for_busy_kernel) &&
kernelTimeout != 0) {
// TODO: this would be awesome if we could connect to rpy2 for R suggestions in Python,
// but this is not the job of this extension; nevertheless its better to keep this in
// mind to avoid introducing design decisions which would make this impossible
// (for other extensions)
// TODO: should it be cashed?
const kernelLanguage = await this._kernel_language();
if (document.language.toLocaleLowerCase() === kernelLanguage.toLowerCase()) {
let default_kernel_promise = this._kernel_connector.fetch(request);
let kernel_promise;
if (kernelTimeout == -1) {
kernel_promise = default_kernel_promise;
}
else {
// implement timeout for the kernel response using Promise.race:
// an empty completion result will resolve after the timeout
// if actual kernel response does not beat it to it
kernel_promise = Promise.race([
default_kernel_promise,
new Promise(resolve => {
return setTimeout(() => resolve({
start: 0,
end: 0,
matches: [],
metadata: {}
}), kernelTimeout);
})
]);
}
promise = Promise.all([
kernel_promise.catch(p => p),
lsp_promise.catch(p => p)
]).then(([kernel, lsp]) => {
let replies = [];
if (kernel != null) {
replies.push(this.transform_reply(kernel));
}
if (lsp != null) {
replies.push(lsp);
}
return this.merge_replies(replies, this._editor);
});
}
}
if (!promise) {
promise = lsp_promise.catch(e => {
this.console.warn('hint failed', e);
return this.fallback_connector
.fetch(request)
.then(this.transform_reply);
});
}
}
catch (e) {
this.console.warn('kernel completions failed', e);
promise = this.fallback_connector
.fetch(request)
.then(this.transform_reply);
}
this.console.debug('All promises set up and ready.');
return promise.then(reply => {
reply = this.suppress_if_needed(reply, token, cursor);
if (reply) {
this.items = reply.items;
}
this.trigger_kind = CompletionTriggerKind.Invoked;
return reply;
});
}
get_connection(uri) {
return this._connections.get(uri);
}
async fetch_lsp(token, typed_character, start, end, cursor, document, position_in_token) {
let connection = this.get_connection(document.uri);
this.console.debug('Fetching');
this.console.debug('Token:', token, start, end);
const trigger_kind = this.trigger_kind == AdditionalCompletionTriggerKinds.AutoInvoked
? CompletionTriggerKind.Invoked
: this.trigger_kind;
let lspCompletionItems = ((await connection.getCompletion(cursor, {
start,
end,
text: token.value
}, document.document_info, false, typed_character, trigger_kind)) || []);
this.console.debug('Transforming');
return transformLSPCompletions(token, position_in_token, lspCompletionItems, (kind, match) => new LazyCompletionItem(kind, this.icon_for(kind), match, this, document.uri), this.console);
}
icon_for(type) {
if (typeof type === 'undefined') {
type = KernelKind;
}
return this.options.themeManager.get_icon(type) || undefined;
}
transform_reply(reply) {
this.console.log('Transforming kernel reply:', reply);
let items;
const metadata = reply.metadata || {};
const types = metadata._jupyter_types_experimental;
if (types) {
items = types.map((item) => {
return {
label: item.text,
insertText: item.text,
type: item.type === '<unknown>' ? undefined : item.type,
icon: this.icon_for(item.type),
sortText: this.kernel_completions_first ? 'a' : 'z'
};
});
}
else {
items = reply.matches.map(match => {
return {
label: match,
insertText: match,
sortText: this.kernel_completions_first ? 'a' : 'z'
};
});
}
return {
start: reply.start,
end: reply.end,
source: {
name: 'Kernel',
priority: 1,
fallbackIcon: this.icon_for('Kernel')
},
items
};
}
merge_replies(replies, editor) {
this.console.debug('Merging completions:', replies);
replies = replies.filter(reply => {
if (reply instanceof Error) {
this.console.warn(`Caught ${reply.source.name} completions error`, reply);
return false;
}
// ignore if no matches
if (!reply.items.length) {
return false;
}
// otherwise keep
return true;
});
replies.sort((a, b) => b.source.priority - a.source.priority);
this.console.debug('Sorted replies:', replies);
const minEnd = Math.min(...replies.map(reply => reply.end));
// if any of the replies uses a wider range, we need to align them
// so that all responses use the same range
const minStart = Math.min(...replies.map(reply => reply.start));
const maxStart = Math.max(...replies.map(reply => reply.start));
if (minStart != maxStart) {
const cursor = editor.getCursorPosition();
const line = editor.getLine(cursor.line);
if (line == null) {
this.console.warn(`Could not remove prefixes: line is undefined`, cursor.line);
}
else {
replies = replies.map(reply => {
// no prefix to strip, return as-is
if (reply.start == maxStart) {
return reply;
}
let prefix = line.substring(reply.start, maxStart);
this.console.debug(`Removing ${reply.source.name} prefix: `, prefix);
return Object.assign(Object.assign({}, reply), { items: reply.items.map(item => {
item.insertText = item.insertText.startsWith(prefix)
? item.insertText.substr(prefix.length)
: item.insertText;
return item;
}) });
});
}
}
const insertTextSet = new Set();
const processedItems = new Array();
for (const reply of replies) {
reply.items.forEach(item => {
// trimming because:
// IPython returns 'import' and 'import '; while the latter is more useful,
// user should not see two suggestions with identical labels and nearly-identical
// behaviour as they could not distinguish the two either way
let text = item.insertText.trim();
if (insertTextSet.has(text)) {
return;
}
insertTextSet.add(text);
// extra processing (adding icon/source name) is delayed until
// we are sure that the item will be kept (as otherwise it could
// lead to processing hundreds of suggestions - e.g. from numpy
// multiple times if multiple sources provide them).
let processedItem = item;
if (reply.source) {
processedItem.source = reply.source;
if (!processedItem.icon) {
processedItem.icon = reply.source.fallbackIcon;
}
}
processedItems.push(processedItem);
});
}
// Return reply with processed items.
this.console.debug('Merged: ', processedItems);
return {
start: maxStart,
end: minEnd,
source: null,
items: processedItems
};
}
list(query) {
return Promise.resolve(undefined);
}
remove(id) {
return Promise.resolve(undefined);
}
save(id, value) {
return Promise.resolve(undefined);
}
suppress_if_needed(reply, token, cursor_at_request) {
if (reply == null) {
return reply;
}
if (!this._editor.hasFocus()) {
this.console.debug('Ignoring completion response: the corresponding editor lost focus');
return {
start: reply.start,
end: reply.end,
items: []
};
}
const cursor_now = this._editor.getCursorPosition();
// if the cursor advanced in the same line, the previously retrieved completions may still be useful
// if the line changed or cursor moved backwards then no reason to keep the suggestions
if (cursor_at_request.line != cursor_now.line ||
cursor_now.column < cursor_at_request.column) {
this.console.debug('Ignoring completion response: cursor has receded or changed line');
return {
start: reply.start,
end: reply.end,
items: []
};
}
if (this.trigger_kind == AdditionalCompletionTriggerKinds.AutoInvoked) {
if (
// do not auto-invoke if no match found
reply.start == reply.end ||
// do not auto-invoke if only one match found and this match is exactly the same as the current token
(reply.items.length === 1 && reply.items[0].insertText === token.value)) {
return {
start: reply.start,
end: reply.end,
items: []
};
}
}
return reply;
}
}
//# sourceMappingURL=completion_handler.js.map