UNPKG

@krassowski/jupyterlab-lsp

Version:

Language Server Protocol integration for JupyterLab

473 lines 21.4 kB
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