diff --git a/package.json b/package.json index 2363b00ab..2344dd34a 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "build:meta": "lerna run build --stream --scope @krassowski/jupyterlab-lsp-metapackage", "build:labextension": "lerna run build:labextension --stream", "build:completion-theme": "lerna run build --stream --scope @krassowski/completion-theme", + "build:completion-manager": "lerna run build --stream --scope @krassowski/completion-manager", "build:theme-vscode": "lerna run build --stream --scope @krassowski/theme-vscode", "build:theme-material": "lerna run build --stream --scope @krassowski/theme-material", "build:jupyterlab-lsp": "lerna run build --stream --scope @krassowski/jupyterlab-lsp", diff --git a/packages/code-snippet-completion/package.json b/packages/code-snippet-completion/package.json new file mode 100644 index 000000000..dd24d2647 --- /dev/null +++ b/packages/code-snippet-completion/package.json @@ -0,0 +1,45 @@ +{ + "name": "code-snippet-completion", + "version": "1.0.0", + "description": "Code Snippet Completion for Jupyter-lsp", + "keywords": [ + "jupyter", + "jupyterlab", + "jupyterlab-extension", + "lsp", + "language-server-protocol", + "code snippet", + "completer" + ], + "homepage": "https://github.com/krassowski/jupyterlab-lsp", + "bugs": { + "url": "https://github.com/krassowski/jupyterlab-lsp/issues" + }, + "author": "Jay Ahn, Kiran Pinnipati", + "license": "BSD-3-Clause", + "files": [ + "{lib,style,schema,src}/**/*.{d.ts,eot,gif,html,jpg,js,js.map,json,png,svg,woff2,ttf,css,json,ts,tsx,txt,md}" + ], + "main": "lib/index.js", + "types": "lib/index.d.ts", + "repository": { + "type": "git", + "url": "https://github.com/krassowski/jupyterlab-lsp.git" + }, + "scripts": { + "build": "tsc -b", + "bundle": "npm pack .", + "clean": "rimraf lib" + }, + "dependencies": {}, + "devDependencies": { + "@krassowski/completion-manager": "~0.0.1", + "@jupyterlab/builder": "^3.0.8" + }, + "peerDependencies": {}, + "jupyterlab": { + "extension": true, + "schemaDir": "schema", + "outputDir": "../../python_packages/jupyterlab_lsp/jupyterlab_lsp/labextensions/@krassowski/code-snippet-completion" + } +} diff --git a/packages/code-snippet-completion/src/index.ts b/packages/code-snippet-completion/src/index.ts new file mode 100644 index 000000000..f8ab080a7 --- /dev/null +++ b/packages/code-snippet-completion/src/index.ts @@ -0,0 +1,19 @@ +import { + JupyterFrontEnd, + JupyterFrontEndPlugin +} from '@jupyterlab/application'; +import { ICompletionProviderManager } from '@krassowski/completion-manager'; + +/** + * Initialization data for the jupyterlab_apod extension. + */ +const extension: JupyterFrontEndPlugin = { + id: 'code-snippet-completion', + autoStart: true, + optional: [ICompletionProviderManager], + activate: (app: JupyterFrontEnd) => { + console.log('JupyterLab extension jupyterlab_apod is activated!'); + } +}; + +export default extension; diff --git a/packages/code-snippet-completion/tsconfig.json b/packages/code-snippet-completion/tsconfig.json new file mode 100644 index 000000000..4a67e0237 --- /dev/null +++ b/packages/code-snippet-completion/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../tsconfigbase", + "compilerOptions": { + "outDir": "lib", + "rootDir": "src", + "types": ["jest"], + "tsBuildInfoFile": "lib/.tsbuildinfo" + }, + "include": ["src/**/*"], + "references": [ + { + "path": "../completion-manager" + } + ] +} diff --git a/packages/completion-manager/package.json b/packages/completion-manager/package.json new file mode 100644 index 000000000..1c4485a98 --- /dev/null +++ b/packages/completion-manager/package.json @@ -0,0 +1,52 @@ +{ + "name": "@krassowski/completion-manager", + "version": "0.0.1", + "description": "Completion manager for JupyterLab-LSP (with aim of upstreaming for JupyterLab 4.0)", + "keywords": [ + "jupyter", + "jupyterlab", + "jupyterlab-extension", + "language-server-protocol", + "completer" + ], + "homepage": "https://github.com/krassowski/jupyterlab-lsp", + "bugs": { + "url": "https://github.com/krassowski/jupyterlab-lsp/issues" + }, + "license": "BSD-3-Clause", + "author": "JupyterLab-LSP Development Team", + "files": [ + "{lib,style,schema,src}/**/*.{d.ts,eot,gif,html,jpg,js,js.map,json,png,svg,woff2,ttf,css,json,ts,tsx,txt,md}" + ], + "main": "lib/index.js", + "types": "lib/index.d.ts", + "repository": { + "type": "git", + "url": "https://github.com/krassowski/jupyterlab-lsp.git" + }, + "scripts": { + "build": "tsc -b", + "bundle": "npm pack .", + "clean": "rimraf lib" + }, + "dependencies": { + "@jupyterlab/application": "^3.0.0", + "@jupyterlab/completer": "^3.0.0" + }, + "devDependencies": { + "@jupyterlab/application": "^3.0.0", + "@jupyterlab/apputils": "^3.0.0", + "@jupyterlab/builder": "^3.0.0", + "@jupyterlab/docregistry": "^3.0.0", + "@jupyterlab/codeeditor": "^3.0.0", + "react": "^17.0.1", + "rimraf": "^3.0.2", + "typescript": "~4.1.3" + }, + "peerDependencies": {}, + "jupyterlab": { + "extension": true, + "schemaDir": "schema", + "outputDir": "../../python_packages/jupyterlab_lsp/jupyterlab_lsp/labextensions/@krassowski/completion-manager" + } +} diff --git a/packages/completion-manager/src/connector.ts b/packages/completion-manager/src/connector.ts new file mode 100644 index 000000000..a09b8a3e3 --- /dev/null +++ b/packages/completion-manager/src/connector.ts @@ -0,0 +1,289 @@ +import { CodeEditor } from '@jupyterlab/codeeditor'; +import { CompletionHandler } from '@jupyterlab/completer'; +import { LabIcon } from '@jupyterlab/ui-components'; + +import { + CompletionTriggerKind, + ICompletionContext, + ICompletionProvider, + ICompletionRequest, + ICompletionSettings, + ICompletionsReply, + IExtendedCompletionItem, + IIconSource +} from './tokens'; + +import ICompletionItemsResponseType = CompletionHandler.ICompletionItemsResponseType; +import ICompletionItemsReply = CompletionHandler.ICompletionItemsReply; + +export interface IMultiSourceCompletionConnectorOptions { + iconSource: IIconSource; + providers: ICompletionProvider[]; + settings: ICompletionSettings; + context: ICompletionContext; +} + +interface IReplyWithProvider extends ICompletionsReply { + provider: ICompletionProvider; +} + +export class MultiSourceCompletionConnector + implements CompletionHandler.ICompletionItemsConnector { + // signal that this is the new type connector (providing completion items) + responseType = ICompletionItemsResponseType; + triggerKind: CompletionTriggerKind; + + constructor(protected options: IMultiSourceCompletionConnectorOptions) {} + + protected get suppress_continuous_hinting_in(): string[] { + return this.options.settings.suppressContinuousHintingIn; + } + + protected get suppress_trigger_character_in(): string[] { + return this.options.settings.suppressTriggerCharacterIn; + } + + async fetch( + request: CompletionHandler.IRequest + ): Promise { + const editor = this.options.context.editor; + const cursor = editor.getCursorPosition(); + const token = editor.getTokenForPosition(cursor); + + if (this.triggerKind == CompletionTriggerKind.AutoInvoked) { + if (this.suppress_continuous_hinting_in.indexOf(token.type) !== -1) { + console.debug('Suppressing completer auto-invoke in', token.type); + return; + } + } else if (this.triggerKind == CompletionTriggerKind.TriggerCharacter) { + if (this.suppress_trigger_character_in.indexOf(token.type) !== -1) { + console.debug('Suppressing completer auto-invoke in', token.type); + return; + } + } + + const promises: Promise[] = []; + + for (const provider of this.options.providers) { + const providerSettings = this.options.settings.providers[ + provider.identifier + ]; + if (!providerSettings.enabled) { + continue; + } + + const wrappedRequest: ICompletionRequest = { + triggerKind: this.triggerKind, + ...request + }; + + await provider.isApplicable(wrappedRequest, this.options.context); + + let promise = provider + .fetch(wrappedRequest, this.options.context) + .then(reply => { + return { + provider: provider, + ...reply + }; + }); + + const timeout = providerSettings.timeout; + + if (timeout != -1) { + // 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 + const timeoutPromise = new Promise(resolve => { + return setTimeout(() => resolve(null), timeout); + }); + + promise = Promise.race([promise, timeoutPromise]); + } + + promises.push(promise.catch(p => p)); + } + + const combinedPromise: Promise = Promise.all( + promises + ).then(replies => { + return this.mergeReplies( + replies.filter(reply => reply != null), + this.options.context.editor + ); + }); + + return combinedPromise.then(reply => { + const transformedReply = this.suppressIfNeeded(reply, token, cursor); + this.triggerKind = CompletionTriggerKind.Invoked; + return transformedReply; + }); + } + + private iconFor(type: string): LabIcon { + return (this.options.iconSource.iconFor(type) as LabIcon) || undefined; + } + + protected mergeReplies( + replies: IReplyWithProvider[], + editor: CodeEditor.IEditor + ): ICompletionsReply { + console.debug('Merging completions:', replies); + + replies = replies.filter(reply => { + if (reply instanceof Error) { + 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; + }); + + // TODO: why sort? should not use sortText instead? + replies.sort((a, b) => b.source.priority - a.source.priority); + + 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); + + replies = replies.map(reply => { + // no prefix to strip, return as-is + if (reply.start == maxStart) { + return reply; + } + let prefix = line.substring(reply.start, maxStart); + 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(); + 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 as IExtendedCompletionItem; + processedItem.source = reply.source; + processedItem.provider = reply.provider; + if (!processedItem.icon) { + // try to get icon based on type or use source fallback if no icon matched + processedItem.icon = + this.iconFor(processedItem.type) || reply.source.fallbackIcon; + } + processedItems.push(processedItem); + }); + } + + // Return reply with processed items. + console.debug('Merged: ', processedItems); + return { + start: maxStart, + end: minEnd, + source: null, + items: processedItems + }; + } + + list( + query: string | undefined + ): Promise<{ + ids: CompletionHandler.IRequest[]; + values: CompletionHandler.ICompletionItemsReply[]; + }> { + return Promise.resolve(undefined); + } + + remove(id: CompletionHandler.IRequest): Promise { + return Promise.resolve(undefined); + } + + save(id: CompletionHandler.IRequest, value: void): Promise { + return Promise.resolve(undefined); + } + + private suppressIfNeeded( + reply: ICompletionsReply, + token: CodeEditor.IToken, + cursor_at_request: CodeEditor.IPosition + ): ICompletionItemsReply { + const editor = this.options.context.editor; + if (!editor.hasFocus()) { + console.debug( + 'Ignoring completion response: the corresponding editor lost focus' + ); + return { + start: reply.start, + end: reply.end, + items: [] + }; + } + + const cursor_now = 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 + ) { + console.debug( + 'Ignoring completion response: cursor has receded or changed line' + ); + return { + start: reply.start, + end: reply.end, + items: [] + }; + } + + if (this.triggerKind == CompletionTriggerKind.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 as ICompletionItemsReply; + } +} diff --git a/packages/completion-manager/src/index.ts b/packages/completion-manager/src/index.ts new file mode 100644 index 000000000..a09588571 --- /dev/null +++ b/packages/completion-manager/src/index.ts @@ -0,0 +1,32 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. +/** + * @packageDocumentation + * @module completer-manager + */ + +import { + JupyterFrontEnd, + JupyterFrontEndPlugin +} from '@jupyterlab/application'; +import { ICompletionManager } from '@jupyterlab/completer'; + +import { CompletionProviderManager } from './manager'; +import { ICompletionProviderManager, PLUGIN_ID } from './tokens'; + +export * from './providers'; +export * from './manager'; +export * from './tokens'; +export * from './model'; + +export const COMPLETION_MANAGER_PLUGIN: JupyterFrontEndPlugin = + { + id: PLUGIN_ID + ':extension', + requires: [ICompletionManager], + autoStart: true, + provides: ICompletionProviderManager, + activate: (app: JupyterFrontEnd, completionManager: ICompletionManager) => { + return new CompletionProviderManager(app, completionManager); + } + }; +export { DispatchRenderer } from './renderer'; diff --git a/packages/completion-manager/src/manager.ts b/packages/completion-manager/src/manager.ts new file mode 100644 index 000000000..c21afec09 --- /dev/null +++ b/packages/completion-manager/src/manager.ts @@ -0,0 +1,122 @@ +import { JupyterFrontEnd } from '@jupyterlab/application'; +import { + CompletionHandler, + ICompletionManager as IOldCompletionManager +} from '@jupyterlab/completer'; +import { NotebookPanel } from '@jupyterlab/notebook'; +import { LabIcon } from '@jupyterlab/ui-components'; + +import { MultiSourceCompletionConnector } from './connector'; +import { GenericCompleterModel } from './model'; +import { DispatchRenderer } from './renderer'; +import { + CompletionTriggerKind, + ICompletionContext, + ICompletionProvider, + ICompletionProviderManager, + ICompletionSettings, + IIconSource +} from './tokens'; + +class NullIconSource implements IIconSource { + iconFor(completionType: string): LabIcon { + return null; + } +} + +export class CompletionProviderManager implements ICompletionProviderManager { + private readonly _providers: Map; + private readonly _renderer: DispatchRenderer; + private _iconSource: IIconSource; + private _connector: MultiSourceCompletionConnector; + private _handler: CompletionHandler; + private _context: ICompletionContext; + private _settings: ICompletionSettings; + + constructor( + private _app: JupyterFrontEnd, + private _oldCompletionManager: IOldCompletionManager + ) { + this._providers = new Map(); + this._iconSource = new NullIconSource(); + this._renderer = new DispatchRenderer(this._providers); + } + + registerProvider(provider: ICompletionProvider): void { + if (this._providers.has(provider.identifier)) { + console.warn(provider.identifier, 'already registered'); + } + this._providers.set(provider.identifier, provider); + } + + getProvider(identifier: string): ICompletionProvider { + return this._providers.get(identifier); + } + + overrideProvider(provider: ICompletionProvider): void { + // TODO + this.registerProvider(provider); + } + + setIconSource(iconSource: IIconSource): void { + this._iconSource = iconSource; + } + + invoke(trigger: CompletionTriggerKind) { + // TODO: ideally this would not re-trigger if list of items not isIncomplete + let command: string; + this._connector.triggerKind = trigger; + + if (this._context.widget instanceof NotebookPanel) { + command = 'completer:invoke-notebook'; + } else { + command = 'completer:invoke-file'; + } + return this._app.commands.execute(command).catch(() => { + this._connector.triggerKind = CompletionTriggerKind.Invoked; + }); + } + + configure(settings: ICompletionSettings) { + Object.assign(this._settings, settings); + } + + // TODO: make it private maybe? This is when LSP would want more control, but it does not make sense to + // have it like that in core. + public connect( + context: ICompletionContext, + model: GenericCompleterModel + ) { + if (this._connector) { + delete this._connector; + } + this._context = context; + this._connector = new MultiSourceCompletionConnector({ + iconSource: this._iconSource, + settings: this._settings, + context: context, + providers: [...this._providers.values()] + }); + this._handler = this._oldCompletionManager.register( + { + connector: this._connector, + editor: context.editor, + parent: context.widget + }, + this._renderer + ) as CompletionHandler; + + let completer = this.completer; + completer.addClass('lsp-completer'); + completer.model = model; + } + + get completer() { + // TODO upstream: make completer public? + return this._handler.completer; + } + + get model() { + return this.completer.model; + } +} diff --git a/packages/completion-manager/src/model.ts b/packages/completion-manager/src/model.ts new file mode 100644 index 000000000..6bb98dd07 --- /dev/null +++ b/packages/completion-manager/src/model.ts @@ -0,0 +1,184 @@ +import { CompleterModel, CompletionHandler } from '@jupyterlab/completer'; +import { StringExt } from '@lumino/algorithm'; + +export interface ICompletionMatch { + /** + * A score which indicates the strength of the match. + * + * A lower score is better. Zero is the best possible score. + */ + score: number; + item: T; +} + +function escapeHTML(text: string) { + let node = document.createElement('span'); + node.textContent = text; + return node.innerHTML; +} + +/** + * This will be contributed upstream + */ +export class GenericCompleterModel< + T extends CompletionHandler.ICompletionItem +> extends CompleterModel { + public settings: GenericCompleterModel.IOptions; + + constructor(settings: GenericCompleterModel.IOptions = {}) { + super(); + // TODO: refactor upstream so that it does not block "options"? + this.settings = { ...GenericCompleterModel.defaultOptions, ...settings }; + } + + completionItems(): T[] { + let query = this.query; + this.query = ''; + let unfilteredItems = super.completionItems() as T[]; + this.query = query; + + // always want to sort + // TODO does this behave strangely with %% if always sorting? + return this._sortAndFilter(query, unfilteredItems); + } + + setCompletionItems(newValue: T[]) { + super.setCompletionItems(newValue); + } + + private _markFragment(value: string): string { + return `${value}`; + } + + protected getFilterText(item: T) { + return this.getHighlightableLabelRegion(item); + } + + protected getHighlightableLabelRegion(item: T) { + // TODO: ideally label and params would be separated so we don't have to do + // things like these which are not language-agnostic + // (assume that params follow after first opening parenthesis which may not be the case); + // the upcoming LSP 3.17 includes CompletionItemLabelDetails + // which separates parameters from the label + // With ICompletionItems, the label may include parameters, so we exclude them from the matcher. + // e.g. Given label `foo(b, a, r)` and query `bar`, + // don't count parameters, `b`, `a`, and `r` as matches. + const index = item.label.indexOf('('); + return index > -1 ? item.label.substring(0, index) : item.label; + } + + private _sortAndFilter(query: string, items: T[]): T[] { + let results: ICompletionMatch[] = []; + + for (let item of items) { + // See if label matches query string + + let matched: boolean; + + let filterText: string = null; + let filterMatch: StringExt.IMatchResult; + + let lowerCaseQuery = query.toLowerCase(); + + if (query) { + filterText = this.getFilterText(item); + if (this.settings.caseSensitive) { + filterMatch = StringExt.matchSumOfSquares(filterText, query); + } else { + filterMatch = StringExt.matchSumOfSquares( + filterText.toLowerCase(), + lowerCaseQuery + ); + } + matched = !!filterMatch; + if (!this.settings.includePerfectMatches) { + matched = matched && filterText != query; + } + } else { + matched = true; + } + + // Filter non-matching items. Filtering may happen on a criterion different than label. + if (matched) { + // If the matches are substrings of label, highlight them + // in this part of the label that can be highlighted (must be a prefix), + // which is intended to avoid highlighting matches in function arguments etc. + let labelMatch: StringExt.IMatchResult; + if (query) { + let labelPrefix = escapeHTML(this.getHighlightableLabelRegion(item)); + if (labelPrefix == filterText) { + labelMatch = filterMatch; + } else { + labelMatch = StringExt.matchSumOfSquares(labelPrefix, query); + } + } + + let label: string; + let score: number; + + if (labelMatch) { + // Highlight label text if there's a match + // there won't be a match if filter text includes additional keywords + // for easier search that are not a part of the label + let marked = StringExt.highlight( + escapeHTML(item.label), + labelMatch.indices, + this._markFragment + ); + label = marked.join(''); + score = labelMatch.score; + } else { + label = escapeHTML(item.label); + score = 0; + } + // preserve getters (allow for lazily retrieved documentation) + const itemClone = Object.create( + Object.getPrototypeOf(item), + Object.getOwnPropertyDescriptors(item) + ); + itemClone.label = label; + // If no insertText is present, preserve original label value + // by setting it as the insertText. + itemClone.insertText = item.insertText ? item.insertText : item.label; + + results.push({ + item: itemClone, + score: score + }); + } + } + + results.sort(this.compareMatches); + + return results.map(x => x.item); + } + + protected compareMatches( + a: ICompletionMatch, + b: ICompletionMatch + ): number { + const delta = a.score - b.score; + if (delta !== 0) { + return delta; + } + return a.item.insertText?.localeCompare(b.item.insertText ?? '') ?? 0; + } +} + +export namespace GenericCompleterModel { + export interface IOptions { + /** + * Whether matching should be case-sensitive (default = true) + */ + caseSensitive?: boolean; + /** + * Whether perfect matches should be included (default = true) + */ + includePerfectMatches?: boolean; + } + + export const defaultOptions: IOptions = { + caseSensitive: true, + includePerfectMatches: true + }; +} diff --git a/packages/completion-manager/src/providers.ts b/packages/completion-manager/src/providers.ts new file mode 100644 index 000000000..4a7225c3c --- /dev/null +++ b/packages/completion-manager/src/providers.ts @@ -0,0 +1,110 @@ +import { ISessionContext } from '@jupyterlab/apputils'; +import { CompletionHandler, KernelConnector } from '@jupyterlab/completer'; +import { LabIcon } from '@jupyterlab/ui-components'; +import { JSONArray, JSONObject } from '@lumino/coreutils'; + +import { + ICompletionRequest, + ICompletionContext, + ICompletionProvider, + ICompletionsReply, + ICompletionsSource, + IExtendedCompletionItem +} from './tokens'; + +export interface IKernelProviderSettings { + waitForBusyKernel: boolean; +} + +export class KernelCompletionProvider implements ICompletionProvider { + identifier = 'kernel'; + private _previousSession: ISessionContext; + private _previousConnector: KernelConnector; + + constructor(public settings: IKernelProviderSettings) { + this._previousSession = null; + } + + protected get _should_wait_for_busy_kernel(): boolean { + return this.settings.waitForBusyKernel; + } + + // define once to avoid creation of many objects + private _source: ICompletionsSource = { + name: 'Kernel', + priority: 1 + }; + + private transform_reply( + reply: CompletionHandler.IReply + ): IExtendedCompletionItem[] { + 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 === '' ? undefined : (item.type as string), + sortText: item.text as string + // sortText: this.kernel_completions_first ? 'a' : 'z' + }; + }); + } else { + items = reply.matches.map(match => { + return { + label: match, + insertText: match, + sortText: match + // TODO add prefix in manager (for all sources depending on priority!) + // sortText: this.kernel_completions_first ? 'a' : 'z' + }; + }); + } + return items; + } + + _getConnector(context: ICompletionContext) { + // TODO: cache + if (this._previousSession != context.sessionContext) { + this._previousConnector = new KernelConnector({ + session: context.sessionContext.session + }); + this._previousSession = context.sessionContext; + } + + return this._previousConnector; + } + + async isApplicable(request: ICompletionRequest, context: ICompletionContext) { + const has_kernel = context.sessionContext.session?.kernel != null; + if (!has_kernel) { + return false; + } + + const is_kernel_idle = + context.sessionContext.session?.kernel?.status == 'idle'; + + return is_kernel_idle || this._should_wait_for_busy_kernel; + } + + async fetch( + request: ICompletionRequest, + context: ICompletionContext + ): Promise { + let kernel_connector = this._getConnector(context); + return kernel_connector.fetch(request).then(reply => { + return { + items: this.transform_reply(reply), + source: this._source + } as ICompletionsReply; + }); + } + + public setFallbackIcon(icon: LabIcon) { + this._source.fallbackIcon = icon; + } +} diff --git a/packages/completion-manager/src/renderer.ts b/packages/completion-manager/src/renderer.ts new file mode 100644 index 000000000..8f2365ac0 --- /dev/null +++ b/packages/completion-manager/src/renderer.ts @@ -0,0 +1,60 @@ +import { Completer, CompletionHandler } from '@jupyterlab/completer'; + +import { ICompletionProvider, IExtendedCompletionItem } from './tokens'; + +export class DispatchRenderer + extends Completer.Renderer + implements Completer.IRenderer { + constructor(protected providers: Map) { + super(); + } + + createCompletionItemNode( + item: IExtendedCompletionItem | CompletionHandler.ICompletionItem, + orderedTypes: string[] + ): HTMLLIElement { + // if there is no provider: use default renderer + if (!(item).provider) { + return super.createCompletionItemNode(item, orderedTypes); + } + // otherwise we must have an extended item. + let extItem = item as IExtendedCompletionItem; + + // make sure that an instance reference, and not an object copy is being used; + if (extItem.self) { + extItem = extItem.self; + } + + if (extItem.provider.renderer) { + return extItem.provider.renderer.createCompletionItemNode( + extItem, + orderedTypes + ); + } + + return super.createCompletionItemNode(item, orderedTypes); + } + + createDocumentationNode( + item: IExtendedCompletionItem | CompletionHandler.ICompletionItem + ): HTMLElement { + // if there is no provider: use default renderer + if (!(item).provider) { + // TODO: add super() here (once new version upstream released) + return; + } + // otherwise we must have an extended item. + let extItem = item as IExtendedCompletionItem; + + // make sure that an instance reference, and not an object copy is being used; + if (extItem.self) { + extItem = extItem.self; + } + + if (extItem.provider.renderer) { + return extItem.provider.renderer.createDocumentationNode(extItem); + } + + // TODO: add super() here (once new version upstream released) + } +} diff --git a/packages/completion-manager/src/tokens.ts b/packages/completion-manager/src/tokens.ts new file mode 100644 index 000000000..09291d9f9 --- /dev/null +++ b/packages/completion-manager/src/tokens.ts @@ -0,0 +1,189 @@ +import { ISessionContext } from '@jupyterlab/apputils'; +import { CodeEditor } from '@jupyterlab/codeeditor'; +import { Completer, CompletionHandler } from '@jupyterlab/completer'; +import { IDocumentWidget } from '@jupyterlab/docregistry'; +import { LabIcon } from '@jupyterlab/ui-components'; +import { Token } from '@lumino/coreutils'; + +import { GenericCompleterModel } from './model'; + +export const PLUGIN_ID = '@krassowski/completion-manager'; + +/** + * Source of the completions (e.g. kernel, lsp, kite, snippets-extension, etc.) + */ +export interface ICompletionsSource { + /** + * The name displayed in the GUI + */ + name: string; + /** + * The higher the number the higher the priority + */ + priority: number; + /** + * The icon to be displayed if no type icon is present + */ + fallbackIcon?: LabIcon; +} + +export interface ICompletionProviderSettings { + timeout: number; + enabled: boolean; +} + +/** + * Source-aware and improved completion item + */ +export interface IExtendedCompletionItem + extends CompletionHandler.ICompletionItem { + insertText: string; + sortText: string; + source?: ICompletionsSource; + /** + * Provider will be added automatically. + */ + provider?: ICompletionProvider; + /** + * Adding self-reference ensures that the original completion object can be accessed from the renderer. + * It is recommended for providers to set self if objects are storing additional dynamic state, + * e.g. by downloading documentation text asynchronously. + */ + self?: IExtendedCompletionItem; +} + +/** + * Completion items reply from a specific source + */ +export interface ICompletionsReply< + T extends IExtendedCompletionItem = IExtendedCompletionItem +> 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 of the completions. A provider can be the source of completions, + * but in general a provider can provide completions from multiple sources, + * for example: + * - LSP can provide completions from multiple language servers for the same document. + * - A machine-learning-based completion provider may provide completions based on algorithm A and algorithm B + */ + source: ICompletionsSource; + items: T[]; +} + +export interface ICompletionContext { + editor: CodeEditor.IEditor; + widget: IDocumentWidget; + // extracted from notebook widget as convenience: + sessionContext?: ISessionContext; +} + +export interface IIconSource { + iconFor(completionType: string): LabIcon; +} + +export interface ICompletionRequest extends CompletionHandler.IRequest { + triggerKind: CompletionTriggerKind; +} + +export interface ICompleterRenderer + extends Completer.IRenderer { + createCompletionItemNode(item: T, orderedTypes: string[]): HTMLLIElement; + createDocumentationNode(item: T): HTMLElement; +} + +export interface ICompletionProvider< + T extends IExtendedCompletionItem = IExtendedCompletionItem +> { + /** + * Unique identifier of the provider + */ + identifier: string; + + /** + * Is completion provider applicable to specified context? + * @param request - useful because we may want to disable specific sources in some parts of document (like sql code cell in a Python notebook) + * @param context + */ + isApplicable( + request: ICompletionRequest, + context: ICompletionContext + ): Promise; + + /** + * Renderer for provider's completions (optional). + */ + renderer?: ICompleterRenderer; + + /** + * Fetch completion requests. + * + * @param request - the completion request text and details + * @param context - additional information about context of completion request + */ + fetch( + request: ICompletionRequest, + context: ICompletionContext + ): Promise>; + + // TODO: not functional yet + /** + * Given an incomplete (unresolved) completion item, resolve it by adding all missing details, + * such as lazy-fetched documentation. + * + * @param completion - the completion item to resolve + */ + resolve?(completion: T): Promise; +} + +enum LSPCompletionTriggerKind { + Invoked = 1, + TriggerCharacter = 2, + TriggerForIncompleteCompletions = 3 +} + +enum AdditionalCompletionTriggerKinds { + AutoInvoked = 9999 +} + +export const CompletionTriggerKind = { + ...LSPCompletionTriggerKind, + ...AdditionalCompletionTriggerKinds +}; +export type CompletionTriggerKind = + | LSPCompletionTriggerKind + | AdditionalCompletionTriggerKinds; + +export interface ICompletionProviderManager { + registerProvider(provider: ICompletionProvider): void; + + getProvider(identifier: string): ICompletionProvider; + + overrideProvider(provider: ICompletionProvider): void; + + setIconSource(iconSource: IIconSource): void; + + invoke(trigger: CompletionTriggerKind): Promise; + + // TODO? + // unregister(provider: ICompletionProvider): void; + + connect( + context: ICompletionContext, + model: GenericCompleterModel + ): void; + + configure(settings: ICompletionSettings): void; +} + +export const ICompletionProviderManager = new Token( + PLUGIN_ID + ':ICompletionProviderManager' +); + +export interface ICompletionSettings { + providers: { + [identifier: string]: ICompletionProviderSettings; + }; + suppressContinuousHintingIn: string[]; + suppressTriggerCharacterIn: string[]; +} diff --git a/packages/completion-manager/transform.js b/packages/completion-manager/transform.js new file mode 100644 index 000000000..8bf3272ad --- /dev/null +++ b/packages/completion-manager/transform.js @@ -0,0 +1,2 @@ +const config = require('./babel.config.js'); +module.exports = require('babel-jest').createTransformer(config); diff --git a/packages/completion-manager/tsconfig.json b/packages/completion-manager/tsconfig.json new file mode 100644 index 000000000..d08155f95 --- /dev/null +++ b/packages/completion-manager/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../tsconfigbase", + "compilerOptions": { + "outDir": "lib", + "rootDir": "src", + "types": ["jest"], + "tsBuildInfoFile": "lib/.tsbuildinfo" + }, + "include": ["src/**/*"] +} diff --git a/packages/completion-theme/src/types.ts b/packages/completion-theme/src/types.ts index fc663066e..ce0fcaefa 100644 --- a/packages/completion-theme/src/types.ts +++ b/packages/completion-theme/src/types.ts @@ -35,7 +35,7 @@ enum CompletionItemKind { export type CompletionItemKindStrings = keyof typeof CompletionItemKind; -export const PLUGIN_ID = '@krassowski/completion-manager'; +export const PLUGIN_ID = '@krassowski/completion-theme'; export type SvgString = string; diff --git a/packages/jupyterlab-lsp/package.json b/packages/jupyterlab-lsp/package.json index 433d0354a..510e9c267 100644 --- a/packages/jupyterlab-lsp/package.json +++ b/packages/jupyterlab-lsp/package.json @@ -59,6 +59,7 @@ "dependencies": { "@krassowski/code-jumpers": "~1.1.0", "@krassowski/completion-theme": "~3.0.0", + "@krassowski/completion-manager": "~0.0.1", "@krassowski/theme-material": "~2.1.0", "@krassowski/theme-vscode": "~2.1.0", "lodash.mergewith": "^4.6.1", diff --git a/packages/jupyterlab-lsp/src/features/completion/completion.ts b/packages/jupyterlab-lsp/src/features/completion/completion.ts index 293ffa11a..d8e45cf91 100644 --- a/packages/jupyterlab-lsp/src/features/completion/completion.ts +++ b/packages/jupyterlab-lsp/src/features/completion/completion.ts @@ -1,28 +1,32 @@ -import { JupyterFrontEnd } from '@jupyterlab/application'; import { CodeEditor } from '@jupyterlab/codeeditor'; -import { CompletionHandler, ICompletionManager } from '@jupyterlab/completer'; +import { CompletionHandler } from '@jupyterlab/completer'; import { IDocumentWidget } from '@jupyterlab/docregistry'; import { NotebookPanel } from '@jupyterlab/notebook'; import { IRenderMimeRegistry } from '@jupyterlab/rendermime'; -import { ILSPCompletionThemeManager } from '@krassowski/completion-theme/lib/types'; +import { LabIcon } from '@jupyterlab/ui-components'; +import { + CompletionTriggerKind, + ICompletionProviderManager +} from '@krassowski/completion-manager'; +import { + ILSPCompletionThemeManager, + KernelKind +} from '@krassowski/completion-theme/lib/types'; import type * as CodeMirror from 'codemirror'; import { CodeCompletion as LSPCompletionSettings } from '../../_completion'; import { IEditorChangedData, WidgetAdapter } from '../../adapters/adapter'; -import { NotebookAdapter } from '../../adapters/notebook/notebook'; import { IDocumentConnectionData } from '../../connection_manager'; import { CodeMirrorIntegration } from '../../editor_integration/codemirror'; import { FeatureSettings, IFeatureLabIntegration } from '../../feature'; -import { - AdditionalCompletionTriggerKinds, - CompletionTriggerKind, - ExtendedCompletionTriggerKind -} from '../../lsp'; import { ILSPAdapterManager, ILSPLogConsole } from '../../tokens'; -import { LSPConnector } from './completion_handler'; import { LazyCompletionItem } from './item'; import { LSPCompleterModel } from './model'; +import { + LSPCompletionProvider, + LSPKernelCompletionProvider +} from './providers'; import { ICompletionData, LSPCompletionRenderer } from './renderer'; const DOC_PANEL_SELECTOR = '.jp-Completer-docpanel'; @@ -74,7 +78,7 @@ export class CompletionCM extends CodeMirrorIntegration { this.settings.composite.continuousHinting ) { (this.feature.labIntegration as CompletionLabIntegration) - .invoke_completer(AdditionalCompletionTriggerKinds.AutoInvoked) + .invoke_completer(CompletionTriggerKind.AutoInvoked) .catch(this.console.warn); } } @@ -82,16 +86,30 @@ export class CompletionCM extends CodeMirrorIntegration { export class CompletionLabIntegration implements IFeatureLabIntegration { // TODO: maybe instead of creating it each time, keep a hash map instead? - protected current_completion_connector: LSPConnector; + protected current_completion_connector: LSPCompletionProvider; protected current_completion_handler: CompletionHandler; protected current_adapter: WidgetAdapter = null; protected renderer: LSPCompletionRenderer; + protected model: LSPCompleterModel; + + protected iconFor(iconType: string) { + if (!this.settings.composite.theme) { + return undefined; + } + if (typeof iconType === 'undefined') { + iconType = KernelKind; + } + return ( + (this.completionThemeManager.get_icon(iconType) as LabIcon) || undefined + ); + } + protected kernelCompletionProvider: LSPKernelCompletionProvider; + protected lspCompletionProvider: LSPCompletionProvider; constructor( - private app: JupyterFrontEnd, - private completionManager: ICompletionManager, + private completionManager: ICompletionProviderManager, public settings: FeatureSettings, - private adapterManager: ILSPAdapterManager, + adapterManager: ILSPAdapterManager, private completionThemeManager: ILSPCompletionThemeManager, private console: ILSPLogConsole, private renderMimeRegistry: IRenderMimeRegistry @@ -107,16 +125,51 @@ export class CompletionLabIntegration implements IFeatureLabIntegration { }); this.renderer.activeChanged.connect(this.active_completion_changed, this); this.renderer.itemShown.connect(this.resolve_and_update, this); + + this.lspCompletionProvider = new LSPCompletionProvider({ + console: console.scope('lsp-provider'), + renderer: this.renderer, + settings: settings + }); + this.kernelCompletionProvider = new LSPKernelCompletionProvider({ + waitForBusyKernel: this.settings.composite.waitForBusyKernel + }); + + completionManager.setIconSource({ iconFor: this.iconFor }); + completionManager.registerProvider(this.lspCompletionProvider); + completionManager.overrideProvider(this.kernelCompletionProvider); + adapterManager.adapterChanged.connect(this.swap_adapter, this); settings.changed.connect(() => { - completionThemeManager.set_theme(this.settings.composite.theme); - completionThemeManager.set_icons_overrides( - this.settings.composite.typesMap - ); + const settings = this.settings.composite; + + completionThemeManager.set_theme(settings.theme); + completionThemeManager.set_icons_overrides(settings.typesMap); if (this.current_completion_handler) { - this.model.settings.caseSensitive = this.settings.composite.caseSensitive; - this.model.settings.includePerfectMatches = this.settings.composite.includePerfectMatches; + this.model.settings.caseSensitive = settings.caseSensitive; + this.model.settings.includePerfectMatches = + settings.includePerfectMatches; } + this.kernelCompletionProvider.settings = { + waitForBusyKernel: settings.waitForBusyKernel + }; + this.kernelCompletionProvider.setFallbackIcon( + completionThemeManager.get_icon('Kernel') as LabIcon + ); + completionManager.configure({ + providers: { + kernel: { + enabled: settings.disableCompletionsFrom.indexOf('Kernel') == -1, + timeout: settings.kernelResponseTimeout + }, + lsp: { + enabled: settings.disableCompletionsFrom.indexOf('LSP') == -1, + timeout: -1 + } + }, + suppressContinuousHintingIn: settings.suppressContinuousHintingIn, + suppressTriggerCharacterIn: settings.suppressTriggerCharacterIn + }); }); } @@ -245,46 +298,26 @@ export class CompletionLabIntegration implements IFeatureLabIntegration { if (editor == null) { return; } - this.set_completion_connector(adapter, editor); - this.current_completion_handler = this.completionManager.register( - { - connector: this.current_completion_connector, - editor: editor, - parent: adapter.widget - }, - this.renderer - ) as CompletionHandler; - let completer = this.completer; - completer.addClass('lsp-completer'); - completer.model = new LSPCompleterModel({ + this.model = new LSPCompleterModel({ caseSensitive: this.settings.composite.caseSensitive, includePerfectMatches: this.settings.composite.includePerfectMatches }); - } - protected get completer() { - // TODO upstream: make completer public? - return this.current_completion_handler.completer; - } + this.completionManager.connect( + { + widget: adapter.widget, + editor: editor, + sessionContext: (this.current_adapter.widget as NotebookPanel) + ?.sessionContext + }, + this.model + ); - protected get model(): LSPCompleterModel { - return this.completer.model as LSPCompleterModel; + this.set_completion_connector(adapter, editor); } - invoke_completer(kind: ExtendedCompletionTriggerKind) { - // TODO: ideally this would not re-trigger if list of items not isIncomplete - let command: string; - this.current_completion_connector.trigger_kind = kind; - - if (this.adapterManager.currentAdapter instanceof NotebookAdapter) { - command = 'completer:invoke-notebook'; - } else { - command = 'completer:invoke-file'; - } - return this.app.commands.execute(command).catch(() => { - this.current_completion_connector.trigger_kind = - CompletionTriggerKind.Invoked; - }); + invoke_completer(kind: CompletionTriggerKind) { + return this.completionManager.invoke(kind); } set_connector( @@ -295,7 +328,7 @@ export class CompletionLabIntegration implements IFeatureLabIntegration { // workaround for current_completion_handler not being there yet this.connect_completion(adapter); } - this.set_completion_connector(adapter, editor_changed.editor); + // this.set_completion_connector(adapter, editor_changed.editor); this.current_completion_handler.editor = editor_changed.editor; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore @@ -362,20 +395,9 @@ export class CompletionLabIntegration implements IFeatureLabIntegration { adapter: WidgetAdapter, editor: CodeEditor.IEditor ) { - if (this.current_completion_connector) { - delete this.current_completion_connector; - } - this.current_completion_connector = new LSPConnector({ - editor: editor, - themeManager: this.completionThemeManager, - connections: this.current_adapter.connection_manager.connections, - virtual_editor: this.current_adapter.virtual_editor, - settings: this.settings, - labIntegration: this, - // it might or might not be a notebook panel (if it is not, the sessionContext and session will just be undefined) - session: (this.current_adapter.widget as NotebookPanel)?.sessionContext - ?.session, - console: this.console - }); + this.kernelCompletionProvider.virtualEditor = adapter.virtual_editor; + + this.lspCompletionProvider.virtualEditor = adapter.virtual_editor; + this.lspCompletionProvider.connections = this.current_adapter.connection_manager.connections; } } diff --git a/packages/jupyterlab-lsp/src/features/completion/completion_handler.ts b/packages/jupyterlab-lsp/src/features/completion/completion_handler.ts deleted file mode 100644 index ec03d23cb..000000000 --- a/packages/jupyterlab-lsp/src/features/completion/completion_handler.ts +++ /dev/null @@ -1,673 +0,0 @@ -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 * 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; - items: IExtendedCompletionItem[]; -} - -/** - * A LSP connector for completion handlers. - */ -export class LSPConnector - implements CompletionHandler.ICompletionItemsConnector { - isDisposed = false; - private _editor: CodeEditor.IEditor; - private _connections: Map; - 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; - 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; - 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; - } - - 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 { - 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 { - let editor = this._editor; - - const cursor = editor.getCursorPosition(); - const token = editor.getTokenForPosition(cursor); - - if (this.trigger_kind == AdditionalCompletionTriggerKinds.AutoInvoked) { - if (this.suppress_continuous_hinting_in.indexOf(token.type) !== -1) { - this.console.debug('Suppressing completer auto-invoke in', token.type); - return; - } - } else if (this.trigger_kind == CompletionTriggerKind.TriggerCharacter) { - if (this.suppress_trigger_character_in.indexOf(token.type) !== -1) { - this.console.debug('Suppressing completer auto-invoke in', token.type); - 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 = this - .use_lsp_completions - ? this.fetch_lsp( - token, - typed_character, - virtual_start, - virtual_end, - virtual_cursor, - document, - position_in_token - ) - : Promise.resolve(null); - - let promise: 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 === kernelLanguage) { - let default_kernel_promise = this._kernel_connector.fetch(request); - let kernel_promise: 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: null, - end: null, - matches: [], - metadata: null - }), - 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); - 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 { - 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'); - let prefix = token.value.slice(0, position_in_token + 1); - let all_non_prefixed = true; - let items: IExtendedCompletionItem[] = []; - lspCompletionItems.forEach(match => { - let kind = match.kind ? CompletionItemKind[match.kind] : ''; - let completionItem = new LazyCompletionItem( - kind, - this.icon_for(kind), - match, - this, - document.uri - ); - - // Update prefix values - let text = match.insertText ? match.insertText : match.label; - 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: - // dispdata → display_tabledata - // display → display_table - // we have to adjust the prefix for the latter (otherwise we would get display_tablelay), - // as we are constrained NOT to replace after the prefix (which would be "disp" otherwise) - prefix = token.value; - } - } - } - - items.push(completionItem); - }); - this.console.debug('Transformed'); - // required to make the repetitive trigger characters like :: or ::: work for R with R languageserver, - // see https://github.com/krassowski/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[''] 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 " 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 " 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; - } - - protected icon_for(type: string): LabIcon { - if (!this.options.settings.composite.theme) { - return undefined; - } - 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 === '' ? 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); - - 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(); - 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 as IExtendedCompletionItem; - 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<{ - ids: CompletionHandler.IRequest[]; - values: CompletionHandler.ICompletionItemsReply[]; - }> { - return Promise.resolve(undefined); - } - - remove(id: CompletionHandler.IRequest): Promise { - return Promise.resolve(undefined); - } - - save(id: CompletionHandler.IRequest, value: void): Promise { - return Promise.resolve(undefined); - } - - private suppress_if_needed( - reply: CompletionHandler.ICompletionItemsReply, - token: CodeEditor.IToken, - cursor_at_request: CodeEditor.IPosition - ) { - 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; - /** - * The connections to be used by the LSP connector. - */ - connections: Map; - - settings: FeatureSettings; - - labIntegration: CompletionLabIntegration; - - themeManager: ILSPCompletionThemeManager; - - session?: Session.ISessionConnection; - - console: ILSPLogConsole; - } -} diff --git a/packages/jupyterlab-lsp/src/features/completion/index.ts b/packages/jupyterlab-lsp/src/features/completion/index.ts index 47b414ffb..2fae2ac3f 100644 --- a/packages/jupyterlab-lsp/src/features/completion/index.ts +++ b/packages/jupyterlab-lsp/src/features/completion/index.ts @@ -2,10 +2,10 @@ import { JupyterFrontEnd, JupyterFrontEndPlugin } from '@jupyterlab/application'; -import { ICompletionManager } from '@jupyterlab/completer'; import { IRenderMimeRegistry } from '@jupyterlab/rendermime'; import { ISettingRegistry } from '@jupyterlab/settingregistry'; import { LabIcon } from '@jupyterlab/ui-components'; +import { ICompletionProviderManager } from '@krassowski/completion-manager'; import { ILSPCompletionThemeManager } from '@krassowski/completion-theme/lib/types'; import completionSvg from '../../../style/icons/completion.svg'; @@ -31,7 +31,7 @@ export const COMPLETION_PLUGIN: JupyterFrontEndPlugin = { requires: [ ILSPFeatureManager, ISettingRegistry, - ICompletionManager, + ICompletionProviderManager, ILSPAdapterManager, ILSPCompletionThemeManager, ILSPLogConsole, @@ -42,7 +42,7 @@ export const COMPLETION_PLUGIN: JupyterFrontEndPlugin = { app: JupyterFrontEnd, featureManager: ILSPFeatureManager, settingRegistry: ISettingRegistry, - completionManager: ICompletionManager, + completionManager: ICompletionProviderManager, adapterManager: ILSPAdapterManager, iconsThemeManager: ILSPCompletionThemeManager, logConsole: ILSPLogConsole, @@ -50,7 +50,6 @@ export const COMPLETION_PLUGIN: JupyterFrontEndPlugin = { ) => { const settings = new FeatureSettings(settingRegistry, FEATURE_ID); const labIntegration = new CompletionLabIntegration( - app, completionManager, settings, adapterManager, diff --git a/packages/jupyterlab-lsp/src/features/completion/item.ts b/packages/jupyterlab-lsp/src/features/completion/item.ts index b69699870..dabcff373 100644 --- a/packages/jupyterlab-lsp/src/features/completion/item.ts +++ b/packages/jupyterlab-lsp/src/features/completion/item.ts @@ -1,38 +1,13 @@ -import { CompletionHandler } from '@jupyterlab/completer'; import { LabIcon } from '@jupyterlab/ui-components'; +import { + ICompletionsSource, + IExtendedCompletionItem +} from '@krassowski/completion-manager'; import * as lsProtocol from 'vscode-languageserver-types'; import { until_ready } from '../../utils'; -import { LSPConnector } from './completion_handler'; - -/** - * To be upstreamed - */ -export interface ICompletionsSource { - /** - * The name displayed in the GUI - */ - name: string; - /** - * The higher the number the higher the priority - */ - priority: number; - /** - * The icon to be displayed if no type icon is present - */ - fallbackIcon?: LabIcon; -} - -/** - * To be upstreamed - */ -export interface IExtendedCompletionItem - extends CompletionHandler.ICompletionItem { - insertText: string; - sortText: string; - source?: ICompletionsSource; -} +import { LSPCompletionProvider } from './providers'; export class LazyCompletionItem implements IExtendedCompletionItem { private _detail: string; @@ -71,7 +46,7 @@ export class LazyCompletionItem implements IExtendedCompletionItem { */ public icon: LabIcon, private match: lsProtocol.CompletionItem, - private connector: LSPConnector, + private connector: LSPCompletionProvider, private uri: string ) { this.label = match.label; diff --git a/packages/jupyterlab-lsp/src/features/completion/model.ts b/packages/jupyterlab-lsp/src/features/completion/model.ts index 68d1508a7..2d6296d94 100644 --- a/packages/jupyterlab-lsp/src/features/completion/model.ts +++ b/packages/jupyterlab-lsp/src/features/completion/model.ts @@ -1,192 +1,13 @@ // Copyright (c) Jupyter Development Team. // Distributed under the terms of the Modified BSD License. -import { CompleterModel, CompletionHandler } from '@jupyterlab/completer'; -import { StringExt } from '@lumino/algorithm'; +import { + GenericCompleterModel, + ICompletionMatch +} from '@krassowski/completion-manager'; import { LazyCompletionItem } from './item'; -interface ICompletionMatch { - /** - * A score which indicates the strength of the match. - * - * A lower score is better. Zero is the best possible score. - */ - score: number; - item: T; -} - -function escapeHTML(text: string) { - let node = document.createElement('span'); - node.textContent = text; - return node.innerHTML; -} - -/** - * This will be contributed upstream - */ -export class GenericCompleterModel< - T extends CompletionHandler.ICompletionItem -> extends CompleterModel { - public settings: GenericCompleterModel.IOptions; - - constructor(settings: GenericCompleterModel.IOptions = {}) { - super(); - // TODO: refactor upstream so that it does not block "options"? - this.settings = { ...GenericCompleterModel.defaultOptions, ...settings }; - } - - completionItems(): T[] { - let query = this.query; - this.query = ''; - let unfilteredItems = super.completionItems() as T[]; - this.query = query; - - // always want to sort - // TODO does this behave strangely with %% if always sorting? - return this._sortAndFilter(query, unfilteredItems); - } - - setCompletionItems(newValue: T[]) { - super.setCompletionItems(newValue); - } - - private _markFragment(value: string): string { - return `${value}`; - } - - protected getFilterText(item: T) { - return this.getHighlightableLabelRegion(item); - } - - protected getHighlightableLabelRegion(item: T) { - // TODO: ideally label and params would be separated so we don't have to do - // things like these which are not language-agnostic - // (assume that params follow after first opening parenthesis which may not be the case); - // the upcoming LSP 3.17 includes CompletionItemLabelDetails - // which separates parameters from the label - // With ICompletionItems, the label may include parameters, so we exclude them from the matcher. - // e.g. Given label `foo(b, a, r)` and query `bar`, - // don't count parameters, `b`, `a`, and `r` as matches. - const index = item.label.indexOf('('); - return index > -1 ? item.label.substring(0, index) : item.label; - } - - private _sortAndFilter(query: string, items: T[]): T[] { - let results: ICompletionMatch[] = []; - - for (let item of items) { - // See if label matches query string - - let matched: boolean; - - let filterText: string = null; - let filterMatch: StringExt.IMatchResult; - - let lowerCaseQuery = query.toLowerCase(); - - if (query) { - filterText = this.getFilterText(item); - if (this.settings.caseSensitive) { - filterMatch = StringExt.matchSumOfSquares(filterText, query); - } else { - filterMatch = StringExt.matchSumOfSquares( - filterText.toLowerCase(), - lowerCaseQuery - ); - } - matched = !!filterMatch; - if (!this.settings.includePerfectMatches) { - matched = matched && filterText != query; - } - } else { - matched = true; - } - - // Filter non-matching items. Filtering may happen on a criterion different than label. - if (matched) { - // If the matches are substrings of label, highlight them - // in this part of the label that can be highlighted (must be a prefix), - // which is intended to avoid highlighting matches in function arguments etc. - let labelMatch: StringExt.IMatchResult; - if (query) { - let labelPrefix = escapeHTML(this.getHighlightableLabelRegion(item)); - if (labelPrefix == filterText) { - labelMatch = filterMatch; - } else { - labelMatch = StringExt.matchSumOfSquares(labelPrefix, query); - } - } - - let label: string; - let score: number; - - if (labelMatch) { - // Highlight label text if there's a match - // there won't be a match if filter text includes additional keywords - // for easier search that are not a part of the label - let marked = StringExt.highlight( - escapeHTML(item.label), - labelMatch.indices, - this._markFragment - ); - label = marked.join(''); - score = labelMatch.score; - } else { - label = escapeHTML(item.label); - score = 0; - } - // preserve getters (allow for lazily retrieved documentation) - const itemClone = Object.create( - Object.getPrototypeOf(item), - Object.getOwnPropertyDescriptors(item) - ); - itemClone.label = label; - // If no insertText is present, preserve original label value - // by setting it as the insertText. - itemClone.insertText = item.insertText ? item.insertText : item.label; - - results.push({ - item: itemClone, - score: score - }); - } - } - - results.sort(this.compareMatches); - - return results.map(x => x.item); - } - - protected compareMatches( - a: ICompletionMatch, - b: ICompletionMatch - ): number { - const delta = a.score - b.score; - if (delta !== 0) { - return delta; - } - return a.item.insertText?.localeCompare(b.item.insertText ?? '') ?? 0; - } -} - -export namespace GenericCompleterModel { - export interface IOptions { - /** - * Whether matching should be case-sensitive (default = true) - */ - caseSensitive?: boolean; - /** - * Whether perfect matches should be included (default = true) - */ - includePerfectMatches?: boolean; - } - export const defaultOptions: IOptions = { - caseSensitive: true, - includePerfectMatches: true - }; -} - export class LSPCompleterModel extends GenericCompleterModel< LazyCompletionItem > { diff --git a/packages/jupyterlab-lsp/src/features/completion/providers.ts b/packages/jupyterlab-lsp/src/features/completion/providers.ts new file mode 100644 index 000000000..390f20fd9 --- /dev/null +++ b/packages/jupyterlab-lsp/src/features/completion/providers.ts @@ -0,0 +1,307 @@ +import { ISessionContext } from '@jupyterlab/apputils'; +import { CodeEditor } from '@jupyterlab/codeeditor'; +import { + ICompletionsReply, + IExtendedCompletionItem, + ICompletionRequest, + CompletionTriggerKind, + ICompletionContext, + ICompletionProvider, + KernelCompletionProvider, + ICompleterRenderer, + IKernelProviderSettings +} from '@krassowski/completion-manager'; +import * 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 { CompletionItemKind } from '../../lsp'; +import { + IEditorPosition, + IRootPosition, + IVirtualPosition +} from '../../positioning'; +import { ILSPLogConsole } from '../../tokens'; +import { VirtualDocument } from '../../virtual/document'; +import { IVirtualEditor } from '../../virtual/editor'; + +import { LazyCompletionItem } from './item'; + +export class LSPCompletionProvider implements ICompletionProvider { + identifier = 'lsp-completion-provider'; + renderer: ICompleterRenderer; + virtualEditor: IVirtualEditor; + connections: Map; + + private _console: ILSPLogConsole; + private _isDisposed = false; + + async isApplicable(request: ICompletionRequest, context: ICompletionContext) { + // const location = get_positions(this.virtualEditor, context.editor); + // TODO: allow to disable LSP completer for specific languages using this method maybe? + return true; + } + + 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: LSPCompletionProvider.IOptions) { + this.renderer = options.renderer; + this._console = options.console; + } + + dispose() { + if (this._isDisposed) { + return; + } + this.connections = null; + this.options = null; + this._isDisposed = true; + } + + public get_connection(uri: string) { + return this.connections.get(uri); + } + + async fetch( + request: ICompletionRequest, + context: ICompletionContext + ): Promise { + const location = get_positions(this.virtualEditor, context.editor); + + const { document, token } = location; + const { start, end, cursor } = location.virtual; + + let connection = this.get_connection(document.uri); + + this._console.debug('Fetching'); + this._console.debug('Token:', token, start, end); + + const trigger_kind = + request.triggerKind == CompletionTriggerKind.AutoInvoked + ? CompletionTriggerKind.Invoked + : request.triggerKind; + + let lspCompletionItems = ((await connection.getCompletion( + cursor, + { + start, + end, + text: token.value + }, + document.document_info, + false, + location.typedCharacter, + trigger_kind + )) || []) as lsProtocol.CompletionItem[]; + + this._console.debug('Transforming'); + let prefix = token.value.slice(0, location.positionInToken + 1); + let all_non_prefixed = true; + let items: IExtendedCompletionItem[] = []; + lspCompletionItems.forEach(match => { + let kind = match.kind ? CompletionItemKind[match.kind] : ''; + let completionItem = new LazyCompletionItem( + kind, + // todo: make sure it is writable0 + null, + match, + this, + document.uri + ); + + // Update prefix values + let text = match.insertText ? match.insertText : match.label; + 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: + // dispdata → display_tabledata + // display → display_table + // we have to adjust the prefix for the latter (otherwise we would get display_tablelay), + // as we are constrained NOT to replace after the prefix (which would be "disp" otherwise) + prefix = token.value; + } + } + } + + items.push(completionItem); + }); + this._console.debug('Transformed'); + // required to make the repetitive trigger characters like :: or ::: work for R with R languageserver, + // see https://github.com/krassowski/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[''] 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 " 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 " 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 namespace for LSP connector statics. + */ +export namespace LSPCompletionProvider { + /** + * The instantiation options for cell completion handlers. + */ + export interface IOptions { + /** + * The connections to be used by the LSP connector. + */ + settings: FeatureSettings; + console: ILSPLogConsole; + renderer: ICompleterRenderer; + } +} + +export interface ILocationTuple { + start: T; + end: T; + cursor: T; +} + +export interface ICompletionLocation { + document: VirtualDocument; + editor: ILocationTuple; + virtual: ILocationTuple; + root: ILocationTuple; + token: CodeEditor.IToken; + positionInToken: number; + typedCharacter: string; +} + +function transform_from_editor_to_root( + virtualEditor: IVirtualEditor, + editor: CodeEditor.IEditor, + position: CodeEditor.IPosition +): IRootPosition { + let editor_position = PositionConverter.ce_to_cm(position) as IEditorPosition; + return virtualEditor.transform_from_editor_to_root(editor, editor_position); +} + +// Mixin to get the document from +function get_positions( + virtualEditor: IVirtualEditor, + editor: CodeEditor.IEditor +): ICompletionLocation { + const cursor = editor.getCursorPosition(); + const token = editor.getTokenForPosition(cursor); + + 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 = transform_from_editor_to_root( + virtualEditor, + editor, + start + ); + let end_in_root = transform_from_editor_to_root(virtualEditor, editor, end); + let cursor_in_root = transform_from_editor_to_root( + virtualEditor, + editor, + cursor + ); + + // find document for position + let document = virtualEditor.document_at_root_position(start_in_root); + + return { + document: document, + virtual: { + start: virtualEditor.root_position_to_virtual_position(start_in_root), + end: virtualEditor.root_position_to_virtual_position(end_in_root), + cursor: virtualEditor.root_position_to_virtual_position(cursor_in_root) + }, + root: { + start: start_in_root, + end: end_in_root, + cursor: cursor_in_root + }, + editor: { + start: start, + end: end, + cursor: cursor + }, + token: token, + positionInToken: position_in_token, + typedCharacter: typed_character + }; +} + +export class LSPKernelCompletionProvider extends KernelCompletionProvider { + // to be set by public setter + virtualEditor: IVirtualEditor; + + private _kernelLanguage: string; + // note: do NOT merge with _previousSession (two different "caches") + private _previousSessionContext: ISessionContext; + + constructor(settings: IKernelProviderSettings) { + super(settings); + this._kernelLanguage = null; + this._previousSessionContext = null; + } + + async isApplicable( + request: ICompletionRequest, + context: ICompletionContext + ): Promise { + let applicable = super.isApplicable(request, context); + if (!applicable) { + return false; + } + + if (this._previousSessionContext != context.sessionContext) { + this._kernelLanguage = ( + await context.sessionContext.session.kernel.info + ).language_info.name; + this._previousSessionContext = context.sessionContext; + } + + const location = get_positions(this.virtualEditor, context.editor); + + return location.document.language === this._kernelLanguage; + } +} diff --git a/packages/jupyterlab-lsp/src/features/completion/renderer.ts b/packages/jupyterlab-lsp/src/features/completion/renderer.ts index 12cf15b13..9585bfcbd 100644 --- a/packages/jupyterlab-lsp/src/features/completion/renderer.ts +++ b/packages/jupyterlab-lsp/src/features/completion/renderer.ts @@ -3,6 +3,7 @@ import { Completer } from '@jupyterlab/completer'; import { IRenderMime } from '@jupyterlab/rendermime'; +import { ICompleterRenderer } from '@krassowski/completion-manager'; import { Signal } from '@lumino/signaling'; import { ILSPLogConsole } from '../../tokens'; @@ -17,7 +18,7 @@ export interface ICompletionData { export class LSPCompletionRenderer extends Completer.Renderer - implements Completer.IRenderer { + implements ICompleterRenderer { // signals public activeChanged: Signal; public itemShown: Signal; @@ -28,7 +29,6 @@ export class LSPCompletionRenderer private elementToItem: WeakMap; private wasActivated: WeakMap; - protected ITEM_PLACEHOLDER_CLASS = 'lsp-detail-placeholder'; protected EXTRA_INFO_CLASS = 'jp-Completer-typeExtended'; constructor(protected options: LSPCompletionRenderer.IOptions) { @@ -120,7 +120,6 @@ export class LSPCompletionRenderer ): HTMLLIElement { const li = super.createCompletionItemNode(item, orderedTypes); - // make sure that an instance reference, and not an object copy is being used; const lsp_item = item.self; // only monitor nodes that have item.self as others are not our completion items diff --git a/packages/jupyterlab-lsp/src/lsp.ts b/packages/jupyterlab-lsp/src/lsp.ts index a187c3939..4bd32ad42 100644 --- a/packages/jupyterlab-lsp/src/lsp.ts +++ b/packages/jupyterlab-lsp/src/lsp.ts @@ -39,20 +39,6 @@ export enum DocumentHighlightKind { Write = 3 } -export enum CompletionTriggerKind { - Invoked = 1, - TriggerCharacter = 2, - TriggerForIncompleteCompletions = 3 -} - -export enum AdditionalCompletionTriggerKinds { - AutoInvoked = 9999 -} - -export type ExtendedCompletionTriggerKind = - | CompletionTriggerKind - | AdditionalCompletionTriggerKinds; - export type CompletionItemKindStrings = keyof typeof CompletionItemKind; /** diff --git a/packages/jupyterlab-lsp/tsconfig.json b/packages/jupyterlab-lsp/tsconfig.json index eae0e7ebe..8be5b18b0 100644 --- a/packages/jupyterlab-lsp/tsconfig.json +++ b/packages/jupyterlab-lsp/tsconfig.json @@ -14,6 +14,9 @@ { "path": "../completion-theme" }, + { + "path": "../completion-manager" + }, { "path": "../theme-vscode" }, diff --git a/packages/metapackage/package.json b/packages/metapackage/package.json index 218424a0e..3116d5d59 100644 --- a/packages/metapackage/package.json +++ b/packages/metapackage/package.json @@ -28,6 +28,7 @@ "dependencies": { "lsp-ws-connection": "file:../lsp-ws-connection", "@krassowski/completion-theme": "file:../completion-theme", + "@krassowski/completion-manager": "file:../completion-manager", "@krassowski/theme-material": "file:../theme-material", "@krassowski/theme-vscode": "file:../theme-vscode", "@krassowski/code-jumpers": "file:../code-jumpers", diff --git a/packages/metapackage/src/index.ts b/packages/metapackage/src/index.ts index 47334c6ef..0ff3dcf26 100644 --- a/packages/metapackage/src/index.ts +++ b/packages/metapackage/src/index.ts @@ -1,5 +1,6 @@ import '@krassowski/code-jumpers'; import '@krassowski/completion-theme'; +import '@krassowski/completion-manager'; import '@krassowski/jupyterlab-lsp'; import '@krassowski/jupyterlab-lsp-example-extractor'; import '@krassowski/theme-material'; diff --git a/packages/metapackage/tsconfig.json b/packages/metapackage/tsconfig.json index 3289d5a9e..729c64e5d 100644 --- a/packages/metapackage/tsconfig.json +++ b/packages/metapackage/tsconfig.json @@ -14,6 +14,9 @@ { "path": "../completion-theme" }, + { + "path": "../completion-manager" + }, { "path": "../theme-material" }, diff --git a/yarn.lock b/yarn.lock index ef8cb0751..ddbeaa9b5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1749,6 +1749,12 @@ "@krassowski/code-jumpers@file:packages/code-jumpers": version "1.1.0" +"@krassowski/completion-manager@file:packages/completion-manager": + version "0.0.1" + dependencies: + "@jupyterlab/application" "^3.0.0" + "@jupyterlab/completer" "^3.0.0" + "@krassowski/completion-theme@file:packages/completion-theme": version "3.0.0" @@ -1761,6 +1767,7 @@ version "3.6.0" dependencies: "@krassowski/code-jumpers" "~1.1.0" + "@krassowski/completion-manager" "~0.0.1" "@krassowski/completion-theme" "~3.0.0" "@krassowski/theme-material" "~2.1.0" "@krassowski/theme-vscode" "~2.1.0"