Skip to content

Commit 4a745d6

Browse files
committed
Refactor completer to support external providers
1 parent 309b3fa commit 4a745d6

File tree

26 files changed

+1480
-979
lines changed

26 files changed

+1480
-979
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: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
1+
import { CodeEditor } from '@jupyterlab/codeeditor';
2+
import { CompletionHandler } from '@jupyterlab/completer';
3+
import { LabIcon } from '@jupyterlab/ui-components';
4+
5+
import {
6+
CompletionTriggerKind,
7+
ICompletionContext,
8+
ICompletionProvider,
9+
ICompletionRequest,
10+
ICompletionSettings,
11+
ICompletionsReply,
12+
IExtendedCompletionItem,
13+
IIconSource
14+
} from './tokens';
15+
16+
import ICompletionItemsResponseType = CompletionHandler.ICompletionItemsResponseType;
17+
import ICompletionItemsReply = CompletionHandler.ICompletionItemsReply;
18+
19+
export interface IMultiSourceCompletionConnectorOptions {
20+
iconSource: IIconSource;
21+
providers: ICompletionProvider[];
22+
settings: ICompletionSettings;
23+
context: ICompletionContext;
24+
}
25+
26+
interface IReplyWithProvider extends ICompletionsReply {
27+
provider: ICompletionProvider;
28+
}
29+
30+
export class MultiSourceCompletionConnector
31+
implements CompletionHandler.ICompletionItemsConnector {
32+
// signal that this is the new type connector (providing completion items)
33+
responseType = ICompletionItemsResponseType;
34+
triggerKind: CompletionTriggerKind;
35+
36+
constructor(protected options: IMultiSourceCompletionConnectorOptions) {}
37+
38+
protected get suppress_continuous_hinting_in(): string[] {
39+
return this.options.settings.suppressContinuousHintingIn;
40+
}
41+
42+
protected get suppress_trigger_character_in(): string[] {
43+
return this.options.settings.suppressTriggerCharacterIn;
44+
}
45+
46+
async fetch(
47+
request: CompletionHandler.IRequest
48+
): Promise<CompletionHandler.ICompletionItemsReply> {
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[
69+
provider.identifier
70+
];
71+
if (!providerSettings.enabled) {
72+
continue;
73+
}
74+
75+
const wrappedRequest: ICompletionRequest = {
76+
triggerKind: this.triggerKind,
77+
...request
78+
};
79+
80+
await provider.isApplicable(wrappedRequest, this.options.context);
81+
82+
let promise = provider
83+
.fetch(wrappedRequest, this.options.context)
84+
.then(reply => {
85+
return {
86+
provider: provider,
87+
...reply
88+
};
89+
});
90+
91+
const timeout = providerSettings.timeout;
92+
93+
if (timeout != -1) {
94+
// implement timeout for the kernel response using Promise.race:
95+
// an empty completion result will resolve after the timeout
96+
// if actual kernel response does not beat it to it
97+
const timeoutPromise = new Promise<IReplyWithProvider>(resolve => {
98+
return setTimeout(() => resolve(null), timeout);
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(
108+
promises
109+
).then(replies => {
110+
return this.mergeReplies(
111+
replies.filter(reply => reply != null),
112+
this.options.context.editor
113+
);
114+
});
115+
116+
return combinedPromise.then(reply => {
117+
const transformedReply = this.suppressIfNeeded(reply, token, cursor);
118+
this.triggerKind = CompletionTriggerKind.Invoked;
119+
return transformedReply;
120+
});
121+
}
122+
123+
private iconFor(type: string): LabIcon {
124+
return (this.options.iconSource.iconFor(type) as LabIcon) || undefined;
125+
}
126+
127+
protected mergeReplies(
128+
replies: IReplyWithProvider[],
129+
editor: CodeEditor.IEditor
130+
): ICompletionsReply {
131+
console.debug('Merging completions:', replies);
132+
133+
replies = replies.filter(reply => {
134+
if (reply instanceof Error) {
135+
console.warn(`Caught ${reply.source.name} completions error`, reply);
136+
return false;
137+
}
138+
// ignore if no matches
139+
if (!reply.items.length) {
140+
return false;
141+
}
142+
// otherwise keep
143+
return true;
144+
});
145+
146+
// TODO: why sort? should not use sortText instead?
147+
replies.sort((a, b) => b.source.priority - a.source.priority);
148+
149+
console.debug('Sorted replies:', replies);
150+
151+
const minEnd = Math.min(...replies.map(reply => reply.end));
152+
153+
// if any of the replies uses a wider range, we need to align them
154+
// so that all responses use the same range
155+
const minStart = Math.min(...replies.map(reply => reply.start));
156+
const maxStart = Math.max(...replies.map(reply => reply.start));
157+
158+
if (minStart != maxStart) {
159+
const cursor = editor.getCursorPosition();
160+
const line = editor.getLine(cursor.line);
161+
162+
replies = replies.map(reply => {
163+
// no prefix to strip, return as-is
164+
if (reply.start == maxStart) {
165+
return reply;
166+
}
167+
let prefix = line.substring(reply.start, maxStart);
168+
console.debug(`Removing ${reply.source.name} prefix: `, prefix);
169+
return {
170+
...reply,
171+
items: reply.items.map(item => {
172+
item.insertText = item.insertText.startsWith(prefix)
173+
? item.insertText.substr(prefix.length)
174+
: item.insertText;
175+
return item;
176+
})
177+
};
178+
});
179+
}
180+
181+
const insertTextSet = new Set<string>();
182+
const processedItems = new Array<IExtendedCompletionItem>();
183+
184+
for (const reply of replies) {
185+
reply.items.forEach(item => {
186+
// trimming because:
187+
// IPython returns 'import' and 'import '; while the latter is more useful,
188+
// user should not see two suggestions with identical labels and nearly-identical
189+
// behaviour as they could not distinguish the two either way
190+
let text = item.insertText.trim();
191+
if (insertTextSet.has(text)) {
192+
return;
193+
}
194+
insertTextSet.add(text);
195+
// extra processing (adding icon/source name) is delayed until
196+
// we are sure that the item will be kept (as otherwise it could
197+
// lead to processing hundreds of suggestions - e.g. from numpy
198+
// multiple times if multiple sources provide them).
199+
let processedItem = item as IExtendedCompletionItem;
200+
processedItem.source = reply.source;
201+
processedItem.provider = reply.provider;
202+
if (!processedItem.icon) {
203+
// try to get icon based on type or use source fallback if no icon matched
204+
processedItem.icon =
205+
this.iconFor(processedItem.type) || reply.source.fallbackIcon;
206+
}
207+
processedItems.push(processedItem);
208+
});
209+
}
210+
211+
// Return reply with processed items.
212+
console.debug('Merged: ', processedItems);
213+
return {
214+
start: maxStart,
215+
end: minEnd,
216+
source: null,
217+
items: processedItems
218+
};
219+
}
220+
221+
list(
222+
query: string | undefined
223+
): Promise<{
224+
ids: CompletionHandler.IRequest[];
225+
values: CompletionHandler.ICompletionItemsReply[];
226+
}> {
227+
return Promise.resolve(undefined);
228+
}
229+
230+
remove(id: CompletionHandler.IRequest): Promise<any> {
231+
return Promise.resolve(undefined);
232+
}
233+
234+
save(id: CompletionHandler.IRequest, value: void): Promise<any> {
235+
return Promise.resolve(undefined);
236+
}
237+
238+
private suppressIfNeeded(
239+
reply: ICompletionsReply,
240+
token: CodeEditor.IToken,
241+
cursor_at_request: CodeEditor.IPosition
242+
): ICompletionItemsReply {
243+
const editor = this.options.context.editor;
244+
if (!editor.hasFocus()) {
245+
console.debug(
246+
'Ignoring completion response: the corresponding editor lost focus'
247+
);
248+
return {
249+
start: reply.start,
250+
end: reply.end,
251+
items: []
252+
};
253+
}
254+
255+
const cursor_now = editor.getCursorPosition();
256+
257+
// if the cursor advanced in the same line, the previously retrieved completions may still be useful
258+
// if the line changed or cursor moved backwards then no reason to keep the suggestions
259+
if (
260+
cursor_at_request.line != cursor_now.line ||
261+
cursor_now.column < cursor_at_request.column
262+
) {
263+
console.debug(
264+
'Ignoring completion response: cursor has receded or changed line'
265+
);
266+
return {
267+
start: reply.start,
268+
end: reply.end,
269+
items: []
270+
};
271+
}
272+
273+
if (this.triggerKind == CompletionTriggerKind.AutoInvoked) {
274+
if (
275+
// do not auto-invoke if no match found
276+
reply.start == reply.end ||
277+
// do not auto-invoke if only one match found and this match is exactly the same as the current token
278+
(reply.items.length === 1 && reply.items[0].insertText === token.value)
279+
) {
280+
return {
281+
start: reply.start,
282+
end: reply.end,
283+
items: []
284+
};
285+
}
286+
}
287+
return reply as ICompletionItemsReply;
288+
}
289+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
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 {
9+
JupyterFrontEnd,
10+
JupyterFrontEndPlugin
11+
} from '@jupyterlab/application';
12+
import { ICompletionManager } from '@jupyterlab/completer';
13+
14+
import { CompletionProviderManager } from './manager';
15+
import { ICompletionProviderManager, PLUGIN_ID } from './tokens';
16+
17+
export * from './providers';
18+
export * from './manager';
19+
export * from './tokens';
20+
export * from './model';
21+
22+
export const COMPLETION_MANAGER_PLUGIN: JupyterFrontEndPlugin<ICompletionProviderManager> = {
23+
id: PLUGIN_ID + ':extension',
24+
requires: [ICompletionManager],
25+
autoStart: true,
26+
activate: (app: JupyterFrontEnd, completionManager: ICompletionManager) => {
27+
return new CompletionProviderManager(app, completionManager);
28+
}
29+
};
30+
export { DispatchRenderer } from './renderer';

0 commit comments

Comments
 (0)