UNPKG

@jupyter-lsp/jupyterlab-lsp

Version:

Language Server Protocol integration for JupyterLab

427 lines (382 loc) 13.5 kB
import { SourceChange } from '@jupyter/ydoc'; import { ILSPCompletionThemeManager } from '@jupyter-lsp/completion-theme'; import { CodeEditor } from '@jupyterlab/codeeditor'; import { ICompletionProvider, CompletionHandler, ICompletionContext, Completer } from '@jupyterlab/completer'; import { IDocumentWidget } from '@jupyterlab/docregistry'; import { ILSPDocumentConnectionManager, IEditorPosition } from '@jupyterlab/lsp'; import { IRenderMimeRegistry } from '@jupyterlab/rendermime'; import { LabIcon } from '@jupyterlab/ui-components'; import type * as lsProtocol from 'vscode-languageserver-protocol'; import { CodeCompletion as LSPCompletionSettings } from '../../_completion'; import { editorPositionToRootPosition, PositionConverter, documentAtRootPosition, rootPositionToVirtualPosition } from '../../converter'; import { FeatureSettings } from '../../feature'; import { CompletionTriggerKind, CompletionItemKind } from '../../lsp'; import { ILSPLogConsole } from '../../tokens'; import { BrowserConsole } from '../../virtual/console'; import { CompletionItem } from './item'; import { LSPCompleterModel } from './model'; import { LSPCompletionRenderer } from './renderer'; interface IOptions { settings: FeatureSettings<LSPCompletionSettings>; renderMimeRegistry: IRenderMimeRegistry; iconsThemeManager: ILSPCompletionThemeManager; connectionManager: ILSPDocumentConnectionManager; } export class CompletionProvider implements ICompletionProvider<CompletionItem> { readonly identifier = 'lsp'; readonly label = 'LSP'; readonly rank = 1000; protected console = new BrowserConsole().scope('Completion provider'); constructor(protected options: IOptions) { const markdownRenderer = options.renderMimeRegistry.createRenderer('text/markdown'); this.renderer = new LSPCompletionRenderer({ settings: options.settings, markdownRenderer, latexTypesetter: options.renderMimeRegistry.latexTypesetter, console: this.console }); } renderer: LSPCompletionRenderer; modelFactory = async ( context: ICompletionContext ): Promise<Completer.IModel> => { 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; }; /** * Resolve (fetch) details such as documentation. */ async resolve(completionItem: CompletionItem): Promise<any> { 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: boolean, changed: SourceChange, context?: ICompletionContext ): boolean { 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?.widget as IDocumentWidget; 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() ) as IEditorPosition; 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 = connection.serverCapabilities?.completionProvider?.triggerCharacters || []; const sourceChange = changed.sourceChange; if (sourceChange == null) { return false; } // do not show completer on deletions if (sourceChange.some(delta => delta.delete != null)) { return false; } const token = editor.getTokenAtCursor(); if (this.options.settings.composite.continuousHinting) { // show completer if: // - token type is known, and // - is not an ignored token type, and // - the source change includes a non-whitespace insertion if ( token.type && !this.options.settings.composite.suppressContinuousHintingIn.includes( token.type ) && sourceChange.some( delta => delta.insert != null && !completerIsVisible && delta.insert.trim().length > 0 ) ) { return true; } } // completer may still be shown due to trigger character if ( !token.type || this.options.settings.composite.suppressTriggerCharacterIn.includes( token.type ) ) { return false; } // show completer if the trigger character was inserted return sourceChange.some( delta => delta.insert != null && triggerCharacters.includes(delta.insert) ); } async fetch( request: CompletionHandler.IRequest, context: ICompletionContext, trigger?: CompletionTriggerKind ): Promise<CompletionHandler.ICompletionItemsReply<CompletionItem>> { const manager = this.options.connectionManager; const widget = context.widget as IDocumentWidget; 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)! ) as IEditorPosition; 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 || [] } as lsProtocol.CompletionList) : lspCompletionReply; return transformLSPCompletions( token, positionInToken, completionList.items, (kind, match) => { return new CompletionItem({ match, connection, type: kind, icon: this.options.iconsThemeManager.getIcon(kind) as LabIcon | null, source: this.label }); }, this.console ); } async isApplicable(context: ICompletionContext): Promise<boolean> { if (this.options.settings.composite.disable) { return false; } const manager = this.options.connectionManager; const widget = context.widget as IDocumentWidget; 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: string): string { return path.slice( path.startsWith("'") || path.startsWith('"') ? 1 : 0, path.endsWith("'") || path.endsWith('"') ? -1 : path.length ); } export function transformLSPCompletions<T>( token: CodeEditor.IToken, positionInToken: number, lspCompletionItems: lsProtocol.CompletionItem[], createCompletionItem: (kind: string, match: lsProtocol.CompletionItem) => T, console: ILSPLogConsole ) { let prefix = token.value.slice(0, positionInToken); let suffix = token.value.slice(positionInToken, token.value.length); let items: T[] = []; // 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; }