UNPKG

@jupyter-lsp/jupyterlab-lsp

Version:

Language Server Protocol integration for JupyterLab

281 lines 12.7 kB
import { editorPositionToRootPosition, PositionConverter, documentAtRootPosition, rootPositionToVirtualPosition } from '../../converter'; import { CompletionTriggerKind, CompletionItemKind } from '../../lsp'; import { BrowserConsole } from '../../virtual/console'; import { CompletionItem } from './item'; import { LSPCompleterModel } from './model'; import { LSPCompletionRenderer } from './renderer'; export class CompletionProvider { constructor(options) { this.options = options; this.identifier = 'lsp'; this.label = 'LSP'; this.rank = 1000; this.console = new BrowserConsole().scope('Completion provider'); this.modelFactory = async (context) => { const composite = this.options.settings.composite; const model = new LSPCompleterModel({ caseSensitive: composite.caseSensitive, preFilterMatches: composite.preFilterMatches, includePerfectMatches: composite.includePerfectMatches, kernelCompletionsFirst: composite.kernelCompletionsFirst }); this.options.settings.changed.connect(() => { const composite = this.options.settings.composite; model.settings.caseSensitive = composite.caseSensitive; model.settings.preFilterMatches = composite.preFilterMatches; model.settings.includePerfectMatches = composite.includePerfectMatches; model.settings.kernelCompletionsFirst = composite.kernelCompletionsFirst; }); return model; }; const markdownRenderer = options.renderMimeRegistry.createRenderer('text/markdown'); this.renderer = new LSPCompletionRenderer({ settings: options.settings, markdownRenderer, latexTypesetter: options.renderMimeRegistry.latexTypesetter, console: this.console }); } /** * Resolve (fetch) details such as documentation. */ async resolve(completionItem) { await completionItem.resolve(); // expand getters return { label: completionItem.label, documentation: completionItem.documentation, deprecated: completionItem.deprecated, detail: completionItem.detail, filterText: completionItem.filterText, sortText: completionItem.sortText, insertText: completionItem.insertText, source: completionItem.source, type: completionItem.type, isDocumentationMarkdown: completionItem.isDocumentationMarkdown, icon: completionItem.icon }; } shouldShowContinuousHint(completerIsVisible, changed, context) { var _a, _b; if (!context) { // waiting for https://github.com/jupyterlab/jupyterlab/pull/15015 due to // https://github.com/jupyterlab/jupyterlab/issues/15014 return false; // throw Error('Completion context was expected'); } const manager = this.options.connectionManager; const widget = context === null || context === void 0 ? void 0 : context.widget; const adapter = manager.adapters.get(widget.context.path); if (!context.editor) { // TODO: why is editor optional in the first place? throw Error('No editor'); } if (!adapter) { throw Error('No adapter'); } const editor = context.editor; const editorPosition = PositionConverter.ce_to_cm(editor.getCursorPosition()); const block = adapter.editors.find(value => value.ceEditor.getEditor() == editor); if (!block) { throw Error('Could not get block with editor'); } const rootPosition = editorPositionToRootPosition(adapter, block.ceEditor, editorPosition); if (!rootPosition) { throw Error('Could not get root position'); } const virtualDocument = documentAtRootPosition(adapter, rootPosition); const connection = manager.connections.get(virtualDocument.uri); if (!connection) { throw Error('Could not find connection for virtual document'); } const triggerCharacters = ((_b = (_a = connection.serverCapabilities) === null || _a === void 0 ? void 0 : _a.completionProvider) === null || _b === void 0 ? void 0 : _b.triggerCharacters) || []; const sourceChange = changed.sourceChange; if (sourceChange == null) { return false; } if (sourceChange.some(delta => delta.delete != null)) { return false; } const token = editor.getTokenAtCursor(); if (this.options.settings.composite.continuousHinting) { // if token type is known and not ignored token type is ignored - show completer if (token.type && !this.options.settings.composite.suppressContinuousHintingIn.includes(token.type)) { return true; } // otherwise show it may still be shown due to trigger character } if (!token.type || this.options.settings.composite.suppressTriggerCharacterIn.includes(token.type)) { return false; } return sourceChange.some(delta => delta.insert != null && (triggerCharacters.includes(delta.insert) || (!completerIsVisible && delta.insert.trim().length > 0))); } async fetch(request, context, trigger) { const manager = this.options.connectionManager; const widget = context.widget; const adapter = manager.adapters.get(widget.context.path); if (!context.editor) { // TODO: why is editor optional in the first place? throw Error('No editor'); } if (!adapter) { throw Error('No adapter'); } const editor = context.editor; const editorPosition = PositionConverter.ce_to_cm(editor.getPositionAt(request.offset)); const token = editor.getTokenAt(request.offset); const positionInToken = request.offset - token.offset; // TODO: (typedCharacter can serve as a proxy for triggerCharacter) const typedCharacter = token.value[positionInToken - 1]; // TODO: direct mapping // because we need editorAccessor, not the editor itself we perform this rather sad dance: const block = adapter.editors.find(value => value.ceEditor.getEditor() == editor); if (!block) { throw Error('Could not get block with editor'); } const rootPosition = editorPositionToRootPosition(adapter, block.ceEditor, editorPosition); if (!rootPosition) { throw Error('Could not get root position'); } const virtualDocument = documentAtRootPosition(adapter, rootPosition); const virtualPosition = rootPositionToVirtualPosition(adapter, rootPosition); const connection = manager.connections.get(virtualDocument.uri); if (!connection) { throw Error('Could not find connection for virtual document'); } const lspCompletionReply = await connection.clientRequests['textDocument/completion'].request({ textDocument: { uri: virtualDocument.documentInfo.uri }, position: { line: virtualPosition.line, character: virtualPosition.ch }, context: { triggerKind: trigger || CompletionTriggerKind.Invoked, triggerCharacter: trigger === CompletionTriggerKind.TriggerCharacter ? typedCharacter : undefined } }); const completionList = !lspCompletionReply || Array.isArray(lspCompletionReply) ? { isIncomplete: false, items: lspCompletionReply || [] } : lspCompletionReply; return transformLSPCompletions(token, positionInToken, completionList.items, (kind, match) => { return new CompletionItem({ match, connection, type: kind, icon: this.options.iconsThemeManager.getIcon(kind), source: this.label }); }, this.console); } async isApplicable(context) { if (this.options.settings.composite.disable) { return false; } const manager = this.options.connectionManager; const widget = context.widget; if (typeof widget.context === 'undefined') { // there is no path for Console as it is not a DocumentWidget return false; } const adapter = manager.adapters.get(widget.context.path); if (!adapter) { return false; } return true; } } function stripQuotes(path) { return path.slice(path.startsWith("'") || path.startsWith('"') ? 1 : 0, path.endsWith("'") || path.endsWith('"') ? -1 : path.length); } export function transformLSPCompletions(token, positionInToken, lspCompletionItems, createCompletionItem, console) { let prefix = token.value.slice(0, positionInToken); let suffix = token.value.slice(positionInToken, token.value.length); let items = []; // If there are no prefixes, we will just insert the text without replacing the token, // which is the case for example in R for `stats::<tab>` which returns module members // without `::` prefix. // If there are prefixes, we will replace the token so we may need to prepend/append to, // or otherwise modify the insert text of individual completion items. let anyPrefixed = false; lspCompletionItems.forEach(match => { let kind = match.kind ? CompletionItemKind[match.kind] : ''; let text = match.insertText ? match.insertText : match.label; let intendedText = match.insertText ? match.insertText : match.label; if (intendedText.toLowerCase().startsWith(prefix.toLowerCase())) { anyPrefixed = true; } // Add overlap with token prefix if (intendedText.startsWith(token.value)) { anyPrefixed = true; // remove overlap with prefix before expanding it if (intendedText.startsWith(prefix)) { text = text.substring(prefix.length, text.length); match.insertText = text; } text = token.value + text; match.insertText = text; } // special handling for paths if (token.type === 'String' && prefix.includes('/')) { const parts = stripQuotes(prefix).split('/'); if (text.toLowerCase().startsWith(parts[parts.length - 1].toLowerCase())) { let pathPrefix = parts.slice(0, -1).join('/') + '/'; text = (prefix.startsWith("'") || prefix.startsWith('"') ? prefix[0] : '') + pathPrefix + text + (suffix.startsWith("'") || suffix.startsWith('"') ? suffix[0] : ''); match.insertText = text; // for label without quotes match.label = pathPrefix + match.label; anyPrefixed = true; } } else { // harmonise end to token if (text.toLowerCase().endsWith(suffix.toLowerCase())) { text = text.substring(0, text.length - suffix.length); match.insertText = text; } else if (token.type === 'String') { // special case for completion in strings to preserve the closing quote; // there is an issue that this gives opposing results in Notebook vs File editor // probably due to reconciliator logic if (suffix.startsWith("'") || suffix.startsWith('"')) { match.insertText = text + suffix[0]; } } } let completionItem = createCompletionItem(kind, match); items.push(completionItem); }); console.debug('Transformed'); let start = token.offset; let end = token.offset + token.value.length; if (!anyPrefixed) { start = end; } let response = { start, end, items, source: 'LSP' }; if (response.start > response.end) { console.warn('Response contains start beyond end; this should not happen!', response); } return response; } //# sourceMappingURL=provider.js.map