Skip to content

Commit 5f91657

Browse files
committed
Refactor completer to support external providers
1 parent 309b3fa commit 5f91657

File tree

26 files changed

+1420
-988
lines changed

26 files changed

+1420
-988
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
"build:meta": "lerna run build --stream --scope @krassowski/jupyterlab-lsp-metapackage",
4545
"build:labextension": "lerna run build:labextension --stream",
4646
"build:completion-theme": "lerna run build --stream --scope @krassowski/completion-theme",
47+
"build:completion-manager": "lerna run build --stream --scope @krassowski/completion-manager",
4748
"build:theme-vscode": "lerna run build --stream --scope @krassowski/theme-vscode",
4849
"build:theme-material": "lerna run build --stream --scope @krassowski/theme-material",
4950
"build:jupyterlab-lsp": "lerna run build --stream --scope @krassowski/jupyterlab-lsp",
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
{
2+
"name": "@krassowski/completion-manager",
3+
"version": "0.0.1",
4+
"description": "Completion manager for JupyterLab-LSP (with aim of upstreaming for JupyterLab 4.0)",
5+
"keywords": [
6+
"jupyter",
7+
"jupyterlab",
8+
"jupyterlab-extension",
9+
"language-server-protocol",
10+
"completer"
11+
],
12+
"homepage": "https://github.com/krassowski/jupyterlab-lsp",
13+
"bugs": {
14+
"url": "https://github.com/krassowski/jupyterlab-lsp/issues"
15+
},
16+
"license": "BSD-3-Clause",
17+
"author": "JupyterLab-LSP Development Team",
18+
"files": [
19+
"{lib,style,schema,src}/**/*.{d.ts,eot,gif,html,jpg,js,js.map,json,png,svg,woff2,ttf,css,json,ts,tsx,txt,md}"
20+
],
21+
"main": "lib/index.js",
22+
"types": "lib/index.d.ts",
23+
"repository": {
24+
"type": "git",
25+
"url": "https://github.com/krassowski/jupyterlab-lsp.git"
26+
},
27+
"scripts": {
28+
"build": "tsc -b",
29+
"bundle": "npm pack .",
30+
"clean": "rimraf lib"
31+
},
32+
"dependencies": {
33+
"@jupyterlab/application": "^3.0.0",
34+
"@jupyterlab/completer": "^3.0.0"
35+
},
36+
"devDependencies": {
37+
"@jupyterlab/application": "^3.0.0",
38+
"@jupyterlab/apputils": "^3.0.0",
39+
"@jupyterlab/builder": "^3.0.0",
40+
"@jupyterlab/docregistry": "^3.0.0",
41+
"@jupyterlab/codeeditor": "^3.0.0",
42+
"react": "^17.0.1",
43+
"rimraf": "^3.0.2",
44+
"typescript": "~4.1.3"
45+
},
46+
"peerDependencies": {},
47+
"jupyterlab": {
48+
"extension": true,
49+
"schemaDir": "schema",
50+
"outputDir": "../../python_packages/jupyterlab_lsp/jupyterlab_lsp/labextensions/@krassowski/completion-manager"
51+
}
52+
}
Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
import { LabIcon } from "@jupyterlab/ui-components";
2+
import {
3+
CompletionTriggerKind,
4+
ICompletionContext,
5+
ICompletionProvider,
6+
ICompletionRequest,
7+
ICompletionSettings,
8+
ICompletionsReply,
9+
IExtendedCompletionItem,
10+
IIconSource
11+
} from "./tokens";
12+
import { CodeEditor } from "@jupyterlab/codeeditor";
13+
import { CompletionHandler } from "@jupyterlab/completer";
14+
import ICompletionItemsResponseType = CompletionHandler.ICompletionItemsResponseType;
15+
import ICompletionItemsReply = CompletionHandler.ICompletionItemsReply;
16+
17+
export interface IMultiSourceCompletionConnectorOptions {
18+
iconSource: IIconSource;
19+
providers: ICompletionProvider[];
20+
settings: ICompletionSettings;
21+
context: ICompletionContext;
22+
}
23+
24+
interface IReplyWithProvider extends ICompletionsReply {
25+
provider: ICompletionProvider;
26+
}
27+
28+
export class MultiSourceCompletionConnector implements CompletionHandler.ICompletionItemsConnector {
29+
30+
// signal that this is the new type connector (providing completion items)
31+
responseType = ICompletionItemsResponseType;
32+
triggerKind: CompletionTriggerKind;
33+
34+
constructor(protected options: IMultiSourceCompletionConnectorOptions) {
35+
}
36+
37+
protected get suppress_continuous_hinting_in(): string[] {
38+
return this.options.settings.suppressContinuousHintingIn;
39+
}
40+
41+
protected get suppress_trigger_character_in(): string[] {
42+
return this.options.settings.suppressTriggerCharacterIn;
43+
}
44+
45+
async fetch(
46+
request: CompletionHandler.IRequest
47+
): Promise<CompletionHandler.ICompletionItemsReply> {
48+
49+
const editor = this.options.context.editor;
50+
const cursor = editor.getCursorPosition();
51+
const token = editor.getTokenForPosition(cursor);
52+
53+
if (this.triggerKind == CompletionTriggerKind.AutoInvoked) {
54+
if (this.suppress_continuous_hinting_in.indexOf(token.type) !== -1) {
55+
console.debug('Suppressing completer auto-invoke in', token.type);
56+
return;
57+
}
58+
} else if (this.triggerKind == CompletionTriggerKind.TriggerCharacter) {
59+
if (this.suppress_trigger_character_in.indexOf(token.type) !== -1) {
60+
console.debug('Suppressing completer auto-invoke in', token.type);
61+
return;
62+
}
63+
}
64+
65+
const promises: Promise<IReplyWithProvider>[] = [];
66+
67+
for (const provider of this.options.providers) {
68+
const providerSettings = this.options.settings.providers[provider.identifier];
69+
if (!providerSettings.enabled) {
70+
continue;
71+
}
72+
73+
const wrappedRequest: ICompletionRequest = {triggerKind: this.triggerKind, ...request};
74+
75+
await provider.isApplicable(wrappedRequest, this.options.context);
76+
77+
let promise = provider.fetch(
78+
wrappedRequest,
79+
this.options.context
80+
).then(reply => {
81+
return {
82+
provider: provider,
83+
...reply
84+
}
85+
});
86+
87+
const timeout = providerSettings.timeout;
88+
89+
if (timeout != -1) {
90+
// implement timeout for the kernel response using Promise.race:
91+
// an empty completion result will resolve after the timeout
92+
// if actual kernel response does not beat it to it
93+
const timeoutPromise = new Promise<IReplyWithProvider>(resolve => {
94+
return setTimeout(
95+
() =>
96+
resolve(null),
97+
timeout
98+
);
99+
})
100+
101+
promise = Promise.race([promise, timeoutPromise]);
102+
}
103+
104+
promises.push(promise.catch(p => p));
105+
}
106+
107+
const combinedPromise: Promise<ICompletionsReply> = Promise.all(promises).then(replies => {
108+
return this.mergeReplies(replies.filter(reply => reply != null), this.options.context.editor);
109+
});
110+
111+
return combinedPromise.then(reply => {
112+
const transformedReply = this.suppressIfNeeded(reply, token, cursor);
113+
this.triggerKind = CompletionTriggerKind.Invoked;
114+
return transformedReply;
115+
});
116+
}
117+
118+
private iconFor(type: string): LabIcon {
119+
return (this.options.iconSource.iconFor(type) as LabIcon) || undefined;
120+
}
121+
122+
protected mergeReplies(
123+
replies: IReplyWithProvider[],
124+
editor: CodeEditor.IEditor
125+
): ICompletionsReply {
126+
console.debug('Merging completions:', replies);
127+
128+
replies = replies.filter(reply => {
129+
if (reply instanceof Error) {
130+
console.warn(
131+
`Caught ${reply.source.name} completions error`,
132+
reply
133+
);
134+
return false;
135+
}
136+
// ignore if no matches
137+
if (!reply.items.length) {
138+
return false;
139+
}
140+
// otherwise keep
141+
return true;
142+
});
143+
144+
// TODO: why sort? should not use sortText instead?
145+
replies.sort((a, b) => b.source.priority - a.source.priority);
146+
147+
console.debug('Sorted replies:', replies);
148+
149+
const minEnd = Math.min(...replies.map(reply => reply.end));
150+
151+
// if any of the replies uses a wider range, we need to align them
152+
// so that all responses use the same range
153+
const minStart = Math.min(...replies.map(reply => reply.start));
154+
const maxStart = Math.max(...replies.map(reply => reply.start));
155+
156+
if (minStart != maxStart) {
157+
const cursor = editor.getCursorPosition();
158+
const line = editor.getLine(cursor.line);
159+
160+
replies = replies.map(reply => {
161+
// no prefix to strip, return as-is
162+
if (reply.start == maxStart) {
163+
return reply;
164+
}
165+
let prefix = line.substring(reply.start, maxStart);
166+
console.debug(`Removing ${reply.source.name} prefix: `, prefix);
167+
return {
168+
...reply,
169+
items: reply.items.map(item => {
170+
item.insertText = item.insertText.startsWith(prefix)
171+
? item.insertText.substr(prefix.length)
172+
: item.insertText;
173+
return item;
174+
})
175+
};
176+
});
177+
}
178+
179+
const insertTextSet = new Set<string>();
180+
const processedItems = new Array<IExtendedCompletionItem>();
181+
182+
for (const reply of replies) {
183+
reply.items.forEach(item => {
184+
// trimming because:
185+
// IPython returns 'import' and 'import '; while the latter is more useful,
186+
// user should not see two suggestions with identical labels and nearly-identical
187+
// behaviour as they could not distinguish the two either way
188+
let text = item.insertText.trim();
189+
if (insertTextSet.has(text)) {
190+
return;
191+
}
192+
insertTextSet.add(text);
193+
// extra processing (adding icon/source name) is delayed until
194+
// we are sure that the item will be kept (as otherwise it could
195+
// lead to processing hundreds of suggestions - e.g. from numpy
196+
// multiple times if multiple sources provide them).
197+
let processedItem = item as IExtendedCompletionItem;
198+
processedItem.source = reply.source;
199+
processedItem.provider = reply.provider;
200+
if (!processedItem.icon) {
201+
// try to get icon based on type or use source fallback if no icon matched
202+
processedItem.icon = this.iconFor(processedItem.type) || reply.source.fallbackIcon;
203+
}
204+
processedItems.push(processedItem);
205+
});
206+
}
207+
208+
// Return reply with processed items.
209+
console.debug('Merged: ', processedItems);
210+
return {
211+
start: maxStart,
212+
end: minEnd,
213+
source: null,
214+
items: processedItems
215+
};
216+
}
217+
218+
list(
219+
query: string | undefined
220+
): Promise<{
221+
ids: CompletionHandler.IRequest[];
222+
values: CompletionHandler.ICompletionItemsReply[];
223+
}> {
224+
return Promise.resolve(undefined);
225+
}
226+
227+
remove(id: CompletionHandler.IRequest): Promise<any> {
228+
return Promise.resolve(undefined);
229+
}
230+
231+
save(id: CompletionHandler.IRequest, value: void): Promise<any> {
232+
return Promise.resolve(undefined);
233+
}
234+
235+
236+
private suppressIfNeeded(
237+
reply: ICompletionsReply,
238+
token: CodeEditor.IToken,
239+
cursor_at_request: CodeEditor.IPosition
240+
): ICompletionItemsReply {
241+
const editor = this.options.context.editor
242+
if (!editor.hasFocus()) {
243+
console.debug(
244+
'Ignoring completion response: the corresponding editor lost focus'
245+
);
246+
return {
247+
start: reply.start,
248+
end: reply.end,
249+
items: []
250+
};
251+
}
252+
253+
const cursor_now = editor.getCursorPosition();
254+
255+
// if the cursor advanced in the same line, the previously retrieved completions may still be useful
256+
// if the line changed or cursor moved backwards then no reason to keep the suggestions
257+
if (
258+
cursor_at_request.line != cursor_now.line ||
259+
cursor_now.column < cursor_at_request.column
260+
) {
261+
console.debug(
262+
'Ignoring completion response: cursor has receded or changed line'
263+
);
264+
return {
265+
start: reply.start,
266+
end: reply.end,
267+
items: []
268+
};
269+
}
270+
271+
if (this.triggerKind == CompletionTriggerKind.AutoInvoked) {
272+
if (
273+
// do not auto-invoke if no match found
274+
reply.start == reply.end ||
275+
// do not auto-invoke if only one match found and this match is exactly the same as the current token
276+
(reply.items.length === 1 && reply.items[0].insertText === token.value)
277+
) {
278+
return {
279+
start: reply.start,
280+
end: reply.end,
281+
items: []
282+
};
283+
}
284+
}
285+
return reply as ICompletionItemsReply;
286+
}
287+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// Copyright (c) Jupyter Development Team.
2+
// Distributed under the terms of the Modified BSD License.
3+
/**
4+
* @packageDocumentation
5+
* @module completer-manager
6+
*/
7+
8+
import { JupyterFrontEnd, JupyterFrontEndPlugin } from '@jupyterlab/application';
9+
import { ICompletionManager } from "@jupyterlab/completer";
10+
import { CompletionProviderManager } from "./manager";
11+
import { ICompletionProviderManager, PLUGIN_ID } from "./tokens";
12+
13+
export * from "./providers";
14+
export * from './manager';
15+
export * from './tokens';
16+
export * from './model';
17+
18+
19+
export const COMPLETION_MANAGER_PLUGIN: JupyterFrontEndPlugin<ICompletionProviderManager> = {
20+
id: PLUGIN_ID + ':extension',
21+
requires: [
22+
ICompletionManager,
23+
],
24+
autoStart: true,
25+
activate: (
26+
app: JupyterFrontEnd,
27+
completionManager: ICompletionManager,
28+
) => {
29+
return new CompletionProviderManager(app, completionManager);
30+
}
31+
};
32+
export { DispatchRenderer } from "./renderer";

0 commit comments

Comments
 (0)