Skip to content

Commit 14492df

Browse files
committed
Initial work on source-aware completion item and reply
1 parent 2564fe7 commit 14492df

File tree

3 files changed

+150
-59
lines changed

3 files changed

+150
-59
lines changed

packages/jupyterlab-lsp/src/features/completion/completion_handler.ts

Lines changed: 117 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,24 @@ import {
3333
import { LabIcon } from '@jupyterlab/ui-components';
3434
import { ILSPLogConsole } from '../../tokens';
3535
import { CompletionLabIntegration } from './completion';
36-
import { LazyCompletionItem } from './item';
36+
import {
37+
ICompletionsSource,
38+
IExtendedCompletionItem,
39+
LazyCompletionItem
40+
} from './item';
3741
import ICompletionItemsResponseType = CompletionHandler.ICompletionItemsResponseType;
3842

43+
/**
44+
* Completion items reply from a specific source
45+
*/
46+
export interface ICompletionsReply
47+
extends CompletionHandler.ICompletionItemsReply {
48+
// TODO: it is not clear when the source is set here and when on IExtendedCompletionItem.
49+
// it might be good to separate the two stages for both interfaces
50+
source: ICompletionsSource;
51+
items: IExtendedCompletionItem[];
52+
}
53+
3954
/**
4055
* A LSP connector for completion handlers.
4156
*/
@@ -255,7 +270,10 @@ export class LSPConnector
255270
kernel_promise.catch(p => p),
256271
lsp_promise.catch(p => p)
257272
]).then(([kernel, lsp]) =>
258-
this.merge_replies(this.transform_reply(kernel), lsp, this._editor)
273+
this.merge_replies(
274+
[this.transform_reply(kernel), lsp],
275+
this._editor
276+
)
259277
);
260278
}
261279
}
@@ -295,7 +313,7 @@ export class LSPConnector
295313
cursor: IVirtualPosition,
296314
document: VirtualDocument,
297315
position_in_token: number
298-
): Promise<CompletionHandler.ICompletionItemsReply> {
316+
): Promise<ICompletionsReply> {
299317
let connection = this.get_connection(document.uri);
300318

301319
this.console.debug('Fetching');
@@ -322,7 +340,7 @@ export class LSPConnector
322340
this.console.debug('Transforming');
323341
let prefix = token.value.slice(0, position_in_token + 1);
324342
let all_non_prefixed = true;
325-
let items: CompletionHandler.ICompletionItem[] = [];
343+
let items: IExtendedCompletionItem[] = [];
326344
lspCompletionItems.forEach(match => {
327345
let kind = match.kind ? CompletionItemKind[match.kind] : '';
328346
let completionItem = new LazyCompletionItem(
@@ -374,7 +392,11 @@ export class LSPConnector
374392
// but it did not work for "from statistics <tab>" and lead to "from statisticsimport" (no space)
375393
start: token.offset + (all_non_prefixed ? prefix_offset : 0),
376394
end: token.offset + prefix.length,
377-
items: items
395+
items: items,
396+
source: {
397+
name: 'LSP',
398+
priority: 2
399+
}
378400
};
379401
if (response.start > response.end) {
380402
console.warn(
@@ -396,11 +418,9 @@ export class LSPConnector
396418
return (this.options.themeManager.get_icon(type) as LabIcon) || undefined;
397419
}
398420

399-
private transform_reply(
400-
reply: CompletionHandler.IReply
401-
): CompletionHandler.ICompletionItemsReply {
421+
private transform_reply(reply: CompletionHandler.IReply): ICompletionsReply {
402422
this.console.log('Transforming kernel reply:', reply);
403-
let items: CompletionHandler.ICompletionItem[];
423+
let items: IExtendedCompletionItem[];
404424
const metadata = reply.metadata || {};
405425
const types = metadata._jupyter_types_experimental as JSONArray;
406426

@@ -419,73 +439,113 @@ export class LSPConnector
419439
return {
420440
label: match,
421441
insertText: match,
422-
icon: this.icon_for('Kernel'),
423442
sortText: this.kernel_completions_first ? 'a' : 'z'
424443
};
425444
});
426445
}
427-
return { start: reply.start, end: reply.end, items };
446+
return {
447+
start: reply.start,
448+
end: reply.end,
449+
source: {
450+
name: 'Kernel',
451+
priority: 1,
452+
fallbackIcon: this.icon_for('Kernel')
453+
},
454+
items
455+
};
428456
}
429457

430-
private merge_replies(
431-
kernel: CompletionHandler.ICompletionItemsReply,
432-
lsp: CompletionHandler.ICompletionItemsReply,
458+
protected merge_replies(
459+
replies: ICompletionsReply[],
433460
editor: CodeEditor.IEditor
434-
): CompletionHandler.ICompletionItemsReply {
435-
this.console.debug('Merging completions:', lsp, kernel);
461+
): ICompletionsReply {
462+
this.console.debug('Merging completions:', replies);
463+
464+
replies = replies.filter(reply => {
465+
if (reply instanceof Error) {
466+
this.console.warn(
467+
`Caught ${reply.source.name} completions error`,
468+
reply
469+
);
470+
return false;
471+
}
472+
// ignore if no matches
473+
if (!reply.items.length) {
474+
return false;
475+
}
476+
// otherwise keep
477+
return true;
478+
});
436479

437-
if (kernel instanceof Error) {
438-
this.console.warn('Caught kernel completions error', kernel);
439-
}
440-
if (lsp instanceof Error) {
441-
this.console.warn('Caught LSP completions error', lsp);
442-
}
480+
replies.sort((a, b) => b.source.priority - a.source.priority);
443481

444-
if (kernel instanceof Error || !kernel.items.length) {
445-
return lsp;
446-
}
447-
if (lsp instanceof Error || !lsp.items.length) {
448-
return kernel;
449-
}
482+
this.console.debug('Sorted replies:', replies);
450483

451-
let prefix = '';
484+
const minEnd = Math.min(...replies.map(reply => reply.end));
452485

453-
// if the kernel used a wider range, get the previous characters to strip the prefix off,
454-
// so that both use the same range
455-
if (lsp.start > kernel.start) {
486+
// if any of the replies uses a wider range, we need to align them
487+
// so that all responses use the same range
488+
const minStart = Math.min(...replies.map(reply => reply.start));
489+
const maxStart = Math.max(...replies.map(reply => reply.start));
490+
491+
if (minStart != maxStart) {
456492
const cursor = editor.getCursorPosition();
457493
const line = editor.getLine(cursor.line);
458-
prefix = line.substring(kernel.start, lsp.start);
459-
this.console.debug('Removing kernel prefix: ', prefix);
460-
} else if (lsp.start < kernel.start) {
461-
this.console.warn('Kernel start > LSP start');
462-
}
463494

464-
// combine completions, de-duping by insertText; LSP completions will show up first, kernel second.
465-
const aggregatedItems = lsp.items.concat(
466-
kernel.items.map(item => {
495+
replies = replies.map(reply => {
496+
// no prefix to strip, return as-is
497+
if (reply.start == maxStart) {
498+
return reply;
499+
}
500+
let prefix = line.substring(reply.start, maxStart);
501+
this.console.debug(`Removing ${reply.source.name} prefix: `, prefix);
467502
return {
468-
...item,
469-
insertText: item.insertText.startsWith(prefix)
470-
? item.insertText.substr(prefix.length)
471-
: item.insertText
503+
...reply,
504+
items: reply.items.map(item => {
505+
item.insertText = item.insertText.startsWith(prefix)
506+
? item.insertText.substr(prefix.length)
507+
: item.insertText;
508+
return item;
509+
})
472510
};
473-
})
474-
);
511+
});
512+
}
513+
475514
const insertTextSet = new Set<string>();
476-
const processedItems = new Array<CompletionHandler.ICompletionItem>();
515+
const processedItems = new Array<IExtendedCompletionItem>();
516+
517+
for (const reply of replies) {
518+
reply.items.forEach(item => {
519+
// trimming because:
520+
// IPython returns 'import' and 'import '; while the latter is more useful,
521+
// user should not see two suggestions with identical labels and nearly-identical
522+
// behaviour as they could not distinguish the two either way
523+
let text = item.insertText.trim();
524+
if (insertTextSet.has(text)) {
525+
return;
526+
}
527+
insertTextSet.add(text);
528+
// extra processing (adding icon/source name) is delayed until
529+
// we are sure that the item will be kept (as otherwise it could
530+
// lead to processing hundreds of suggestions - e.g. from numpy
531+
// multiple times if multiple sources provide them).
532+
let processedItem = item as IExtendedCompletionItem;
533+
processedItem.source = reply.source;
534+
if (!processedItem.icon) {
535+
processedItem.icon = reply.source.fallbackIcon;
536+
}
537+
processedItems.push(processedItem);
538+
});
539+
}
477540

478-
aggregatedItems.forEach(item => {
479-
if (insertTextSet.has(item.insertText)) {
480-
return;
481-
}
482-
insertTextSet.add(item.insertText);
483-
processedItems.push(item);
484-
});
485-
// TODO: Sort items
486541
// Return reply with processed items.
487-
this.console.debug('Merged: ', { ...lsp, items: processedItems });
488-
return { ...lsp, items: processedItems };
542+
this.console.debug('Merged: ', processedItems);
543+
return {
544+
start: maxStart,
545+
end: minEnd,
546+
source: null,
547+
items: processedItems
548+
};
489549
}
490550

491551
list(

packages/jupyterlab-lsp/src/features/completion/item.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,35 @@ import { LabIcon } from '@jupyterlab/ui-components';
33
import * as lsProtocol from 'vscode-languageserver-types';
44
import { LSPConnector } from './completion_handler';
55

6-
export class LazyCompletionItem implements CompletionHandler.ICompletionItem {
6+
/**
7+
* To be upstreamed
8+
*/
9+
export interface ICompletionsSource {
10+
/**
11+
* The name displayed in the GUI
12+
*/
13+
name: string;
14+
/**
15+
* The higher the number the higher the priority
16+
*/
17+
priority: number;
18+
/**
19+
* The icon to be displayed if no type icon is present
20+
*/
21+
fallbackIcon?: LabIcon;
22+
}
23+
24+
/**
25+
* To be upstreamed
26+
*/
27+
export interface IExtendedCompletionItem
28+
extends CompletionHandler.ICompletionItem {
29+
insertText: string;
30+
sortText: string;
31+
source?: ICompletionsSource;
32+
}
33+
34+
export class LazyCompletionItem implements IExtendedCompletionItem {
735
private _detail: string;
836
private _documentation: string;
937
private _is_documentation_markdown: boolean;
@@ -27,6 +55,8 @@ export class LazyCompletionItem implements CompletionHandler.ICompletionItem {
2755
*/
2856
public label: string;
2957

58+
public source: ICompletionsSource;
59+
3060
constructor(
3161
/**
3262
* Type of this completion item.

packages/jupyterlab-lsp/src/features/completion/model.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,8 @@ export class GenericCompleterModel<
7979
if (query) {
8080
filterText = this.getFilterText(item);
8181
filterMatch = StringExt.matchSumOfSquares(filterText, query);
82-
matched = !!filterMatch;
82+
// ignore perfect matches (those are not useful)
83+
matched = !!filterMatch && filterText != query;
8384
} else {
8485
matched = true;
8586
}

0 commit comments

Comments
 (0)