@krassowski/jupyterlab-lsp
Version:
Language Server Protocol integration for JupyterLab
730 lines (651 loc) • 23.3 kB
text/typescript
import { CodeEditor } from '@jupyterlab/codeeditor';
import {
CompletionConnector,
CompletionHandler,
ContextConnector,
KernelConnector
} from '@jupyterlab/completer';
import { Session } from '@jupyterlab/services';
import { LabIcon } from '@jupyterlab/ui-components';
import {
ILSPCompletionThemeManager,
KernelKind
} from '@krassowski/completion-theme/lib/types';
import { JSONArray, JSONObject } from '@lumino/coreutils';
import type * as lsProtocol from 'vscode-languageserver-types';
import { CodeCompletion as LSPCompletionSettings } from '../../_completion';
import { LSPConnection } from '../../connection';
import { PositionConverter } from '../../converter';
import { FeatureSettings } from '../../feature';
import {
AdditionalCompletionTriggerKinds,
CompletionItemKind,
CompletionTriggerKind,
ExtendedCompletionTriggerKind
} from '../../lsp';
import {
IEditorPosition,
IRootPosition,
IVirtualPosition
} from '../../positioning';
import { ILSPLogConsole } from '../../tokens';
import { VirtualDocument } from '../../virtual/document';
import { IVirtualEditor } from '../../virtual/editor';
import { CompletionLabIntegration } from './completion';
import {
ICompletionsSource,
IExtendedCompletionItem,
LazyCompletionItem
} from './item';
import ICompletionItemsResponseType = CompletionHandler.ICompletionItemsResponseType;
/**
* Completion items reply from a specific source
*/
export interface ICompletionsReply
extends CompletionHandler.ICompletionItemsReply {
// TODO: it is not clear when the source is set here and when on IExtendedCompletionItem.
// it might be good to separate the two stages for both interfaces
source: ICompletionsSource | null;
items: IExtendedCompletionItem[];
}
export function transformLSPCompletions<T>(
token: CodeEditor.IToken,
position_in_token: number,
lspCompletionItems: lsProtocol.CompletionItem[],
createCompletionItem: (kind: string, match: lsProtocol.CompletionItem) => T,
console: ILSPLogConsole
) {
let prefix = token.value.slice(0, position_in_token + 1);
let all_non_prefixed = true;
let items: T[] = [];
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
implements CompletionHandler.ICompletionItemsConnector
{
isDisposed = false;
private _editor: CodeEditor.IEditor;
private _connections: Map<VirtualDocument.uri, LSPConnection>;
private _context_connector: ContextConnector;
private _kernel_connector: KernelConnector;
private _kernel_and_context_connector: CompletionConnector;
private console: ILSPLogConsole;
// signal that this is the new type connector (providing completion items)
responseType = ICompletionItemsResponseType;
virtual_editor: IVirtualEditor<CodeEditor.IEditor>;
trigger_kind: ExtendedCompletionTriggerKind;
lab_integration: CompletionLabIntegration;
items: CompletionHandler.ICompletionItems;
get kernel_completions_first(): boolean {
return this.options.settings.composite.kernelCompletionsFirst;
}
protected get use_lsp_completions(): boolean {
return (
this.options.settings.composite.disableCompletionsFrom.indexOf('LSP') ==
-1
);
}
protected get use_kernel_completions(): boolean {
return (
this.options.settings.composite.disableCompletionsFrom.indexOf(
'Kernel'
) == -1
);
}
protected get suppress_continuous_hinting_in(): string[] {
return this.options.settings.composite.suppressContinuousHintingIn;
}
protected get suppress_trigger_character_in(): string[] {
return this.options.settings.composite.suppressTriggerCharacterIn;
}
get should_show_documentation(): boolean {
return this.options.settings.composite.showDocumentation;
}
/**
* Create a new LSP connector for completion requests.
*
* @param options - The instantiation options for the LSP connector.
*/
constructor(protected options: LSPConnector.IOptions) {
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;
}
dispose() {
if (this.isDisposed) {
return;
}
this._connections = null as any;
this.virtual_editor = null as any;
this._context_connector = null as any;
this._kernel_connector = null as any;
this._kernel_and_context_connector = null as any;
this.options = null as any;
this._editor = null as any;
this.isDisposed = true;
}
protected get _has_kernel(): boolean {
return this.options.session?.kernel != null;
}
protected get _is_kernel_idle(): boolean {
return this.options.session?.kernel?.status == 'idle';
}
protected get _should_wait_for_busy_kernel(): boolean {
return this.lab_integration.settings.composite.waitForBusyKernel;
}
protected async _kernel_language(): Promise<string> {
return (await this.options.session!.kernel!.info).language_info.name;
}
protected get _kernel_timeout(): number {
return this.lab_integration.settings.composite.kernelResponseTimeout;
}
get fallback_connector() {
return this._kernel_and_context_connector
? this._kernel_and_context_connector
: this._context_connector;
}
protected transform_from_editor_to_root(
position: CodeEditor.IPosition
): IRootPosition {
let editor_position = PositionConverter.ce_to_cm(
position
) as IEditorPosition;
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: CompletionHandler.IRequest
): Promise<CompletionHandler.ICompletionItemsReply | undefined> {
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: Promise<
CompletionHandler.ICompletionItemsReply | undefined
> = 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: Promise<
CompletionHandler.ICompletionItemsReply | undefined
> | null = 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: Promise<CompletionHandler.IReply>;
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<CompletionHandler.IReply>(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;
});
}
public get_connection(uri: string) {
return this._connections.get(uri);
}
async fetch_lsp(
token: CodeEditor.IToken,
typed_character: string,
start: IVirtualPosition,
end: IVirtualPosition,
cursor: IVirtualPosition,
document: VirtualDocument,
position_in_token: number
): Promise<ICompletionsReply> {
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
)) || []) as lsProtocol.CompletionItem[];
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
);
}
protected icon_for(type: string): LabIcon {
if (typeof type === 'undefined') {
type = KernelKind;
}
return (this.options.themeManager.get_icon(type) as LabIcon) || undefined;
}
private transform_reply(reply: CompletionHandler.IReply): ICompletionsReply {
this.console.log('Transforming kernel reply:', reply);
let items: IExtendedCompletionItem[];
const metadata = reply.metadata || {};
const types = metadata._jupyter_types_experimental as JSONArray;
if (types) {
items = types.map((item: JSONObject) => {
return {
label: item.text as string,
insertText: item.text as string,
type: item.type === '<unknown>' ? undefined : (item.type as string),
icon: this.icon_for(item.type as string),
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
};
}
protected merge_replies(
replies: ICompletionsReply[],
editor: CodeEditor.IEditor
): ICompletionsReply {
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 {
...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<string>();
const processedItems = new Array<IExtendedCompletionItem>();
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 as IExtendedCompletionItem;
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: string | undefined): Promise<any> {
return Promise.resolve(undefined);
}
remove(id: CompletionHandler.IRequest): Promise<any> {
return Promise.resolve(undefined);
}
save(id: CompletionHandler.IRequest, value: void): Promise<any> {
return Promise.resolve(undefined);
}
private suppress_if_needed(
reply: CompletionHandler.ICompletionItemsReply | undefined,
token: CodeEditor.IToken,
cursor_at_request: CodeEditor.IPosition
) {
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;
}
}
/**
* A namespace for LSP connector statics.
*/
export namespace LSPConnector {
/**
* The instantiation options for cell completion handlers.
*/
export interface IOptions {
/**
* The editor used by the LSP connector.
*/
editor: CodeEditor.IEditor;
virtual_editor: IVirtualEditor<CodeEditor.IEditor>;
/**
* The connections to be used by the LSP connector.
*/
connections: Map<VirtualDocument.id_path, LSPConnection>;
settings: FeatureSettings<LSPCompletionSettings>;
labIntegration: CompletionLabIntegration;
themeManager: ILSPCompletionThemeManager;
session?: Session.ISessionConnection | null;
console: ILSPLogConsole;
}
}