Skip to content

Commit 6eacd10

Browse files
committed
Fix/improve completion sorting
1 parent 6a15ba1 commit 6eacd10

File tree

5 files changed

+152
-64
lines changed

5 files changed

+152
-64
lines changed

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

Lines changed: 45 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,6 @@ namespace CompletionItem {
3030
}
3131

3232
export class CompletionItem implements IExtendedCompletionItem {
33-
private _detail: string | undefined;
34-
private _documentation: string | undefined;
35-
private _isDocumentationMarkdown: boolean;
36-
private _resolved: boolean;
3733
/**
3834
* Self-reference to make sure that the instance for will remain accessible
3935
* after any copy operation (whether via spread syntax or Object.assign)
@@ -42,7 +38,6 @@ export class CompletionItem implements IExtendedCompletionItem {
4238
public self: CompletionItem;
4339
public element: HTMLLIElement;
4440
public source: string;
45-
private _currentInsertText: string;
4641

4742
get isDocumentationMarkdown(): boolean {
4843
return this._isDocumentationMarkdown;
@@ -55,15 +50,14 @@ export class CompletionItem implements IExtendedCompletionItem {
5550
public label: string;
5651

5752
icon: LabIcon | undefined;
58-
private match: lsProtocol.CompletionItem;
5953

6054
constructor(protected options: CompletionItem.IOptions) {
6155
const match = options.match;
6256
this.label = match.label;
6357
this._setDocumentation(match.documentation);
6458
this._resolved = false;
6559
this._detail = match.detail;
66-
this.match = match;
60+
this._match = match;
6761
this.self = this;
6862
this.source = options.source;
6963
this.icon = options.icon ? options.icon : undefined;
@@ -74,48 +68,38 @@ export class CompletionItem implements IExtendedCompletionItem {
7468
// Ideally this would be fixed and tested e2e in JupyterLab 4.0.7.
7569
// https://github.com/jupyterlab/jupyterlab/issues/15125
7670
makeGetterEnumerable(this, 'insertText');
71+
makeGetterEnumerable(this, 'sortText');
72+
makeGetterEnumerable(this, 'filterText');
7773
}
7874

7975
get type() {
8076
return this.options.type;
8177
}
8278

83-
private _setDocumentation(
84-
documentation: string | lsProtocol.MarkupContent | undefined
85-
) {
86-
if (lsProtocol.MarkupContent.is(documentation)) {
87-
this._documentation = documentation.value;
88-
this._isDocumentationMarkdown = documentation.kind === 'markdown';
89-
} else {
90-
this._documentation = documentation;
91-
this._isDocumentationMarkdown = false;
92-
}
93-
}
94-
9579
/**
9680
* Completion to be inserted.
9781
*/
9882
get insertText(): string {
99-
return this._currentInsertText || this.match.insertText || this.match.label;
83+
return (
84+
this._currentInsertText || this._match.insertText || this._match.label
85+
);
10086
}
101-
10287
set insertText(text: string) {
10388
this._currentInsertText = text;
10489
}
10590

10691
get sortText(): string {
107-
return this.match.sortText || this.match.label;
92+
return this._currentSortText || this._match.sortText || this._match.label;
93+
}
94+
set sortText(text: string) {
95+
this._currentSortText = text;
10896
}
10997

11098
get filterText(): string | undefined {
111-
return this.match.filterText;
99+
return this._match.filterText;
112100
}
113-
114-
private _supportsResolution(): boolean {
115-
const connection = this.options.connection;
116-
return (
117-
connection.serverCapabilities.completionProvider?.resolveProvider ?? false
118-
);
101+
set filterText(text: string | undefined) {
102+
this._match.filterText = text;
119103
}
120104

121105
get detail(): string | undefined {
@@ -153,7 +137,7 @@ export class CompletionItem implements IExtendedCompletionItem {
153137

154138
const resolvedCompletionItem = await connection.clientRequests[
155139
'completionItem/resolve'
156-
].request(this.match);
140+
].request(this._match);
157141

158142
if (resolvedCompletionItem === null) {
159143
return this;
@@ -181,16 +165,43 @@ export class CompletionItem implements IExtendedCompletionItem {
181165
* Indicates if the item is deprecated.
182166
*/
183167
get deprecated(): boolean {
184-
if (this.match.deprecated) {
185-
return this.match.deprecated;
168+
if (this._match.deprecated) {
169+
return this._match.deprecated;
186170
}
187171
return (
188-
this.match.tags != null &&
189-
this.match.tags.some(
172+
this._match.tags != null &&
173+
this._match.tags.some(
190174
tag => tag == lsProtocol.CompletionItemTag.Deprecated
191175
)
192176
);
193177
}
178+
179+
private _setDocumentation(
180+
documentation: string | lsProtocol.MarkupContent | undefined
181+
) {
182+
if (lsProtocol.MarkupContent.is(documentation)) {
183+
this._documentation = documentation.value;
184+
this._isDocumentationMarkdown = documentation.kind === 'markdown';
185+
} else {
186+
this._documentation = documentation;
187+
this._isDocumentationMarkdown = false;
188+
}
189+
}
190+
191+
private _supportsResolution(): boolean {
192+
const connection = this.options.connection;
193+
return (
194+
connection.serverCapabilities.completionProvider?.resolveProvider ?? false
195+
);
196+
}
197+
198+
private _detail: string | undefined;
199+
private _documentation: string | undefined;
200+
private _isDocumentationMarkdown: boolean;
201+
private _resolved: boolean;
202+
private _currentInsertText: string;
203+
private _currentSortText: string;
204+
private _match: lsProtocol.CompletionItem;
194205
}
195206

196207
function makeGetterEnumerable(instance: object, name: string) {

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

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,15 @@ describe('LSPCompleterModel', () => {
88

99
function createDummyItem(
1010
match: lsProtocol.CompletionItem,
11-
type: string = 'dummy'
11+
type: string = 'dummy',
12+
source: string = 'lsp'
1213
) {
1314
return new CompletionItem({
1415
type,
1516
icon: null as any,
1617
match,
1718
connection: null as any,
18-
source: 'lsp'
19+
source: source
1920
});
2021
}
2122

@@ -79,6 +80,68 @@ describe('LSPCompleterModel', () => {
7980
expect(sortedItems.map(item => item.sortText)).toEqual(['a', 'b', 'c']);
8081
});
8182

83+
describe('sorting by source', () => {
84+
const testCompletionA = createDummyItem(
85+
{
86+
label: 'test'
87+
},
88+
'a',
89+
'LSP'
90+
);
91+
const testCompletionB = createDummyItem(
92+
{
93+
label: 'test'
94+
},
95+
'b',
96+
'kernel'
97+
);
98+
const testCompletionC = createDummyItem(
99+
{
100+
label: 'test'
101+
},
102+
'c',
103+
'context'
104+
);
105+
const testCompletionD = createDummyItem(
106+
{
107+
label: 'test'
108+
},
109+
'd',
110+
'unknown'
111+
);
112+
const completionsFromDifferentSources = [
113+
testCompletionA,
114+
testCompletionC,
115+
testCompletionB
116+
];
117+
118+
it('completions are sorted by source', () => {
119+
model.setCompletionItems(completionsFromDifferentSources);
120+
model.query = 'test';
121+
let sortedItems = model.completionItems();
122+
expect(sortedItems.map(item => item.type)).toEqual(['a', 'b', 'c']);
123+
});
124+
125+
it('kernel completions can be prioritised', () => {
126+
model.setCompletionItems(completionsFromDifferentSources);
127+
model.query = 'test';
128+
model.settings.kernelCompletionsFirst = true;
129+
let sortedItems = model.completionItems();
130+
expect(sortedItems.map(item => item.type)).toEqual(['b', 'a', 'c']);
131+
});
132+
133+
it('completions from unknown source land at the end', () => {
134+
model.setCompletionItems([
135+
testCompletionD,
136+
...completionsFromDifferentSources
137+
]);
138+
model.query = 'test';
139+
let sortedItems = model.completionItems();
140+
const types = sortedItems.map(item => item.type);
141+
expect(types[types.length - 1]).toEqual('d');
142+
});
143+
});
144+
82145
it('ignores perfect matches when asked', () => {
83146
model = new LSPCompleterModel({
84147
includePerfectMatches: false

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

Lines changed: 29 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -38,14 +38,16 @@ export class GenericCompleterModel<
3838

3939
completionItems(): T[] {
4040
let query = this.query;
41-
// TODO: make use of `processedItemsCache` when made available upstream,
42-
// see https://github.com/jupyterlab/jupyterlab/pull/15025
4341
// (setting query is bad because it resets the cache; ideally we would
4442
// modify the sorting and filtering algorithm upstream).
43+
44+
// TODO processedItemsCache
4545
this.query = '';
46+
4647
let unfilteredItems = (
4748
super.completionItems() as CompletionHandler.ICompletionItem[]
4849
).map(this.harmoniseItem);
50+
4951
this.query = query;
5052

5153
// always want to sort
@@ -178,7 +180,7 @@ export class GenericCompleterModel<
178180
}
179181
}
180182

181-
results.sort(this.compareMatches);
183+
results.sort(this.compareMatches.bind(this));
182184

183185
return results.map(x => x.item);
184186
}
@@ -206,14 +208,19 @@ export namespace GenericCompleterModel {
206208
*/
207209
includePerfectMatches?: boolean;
208210
/**
209-
* Wheteher matches should be pre-filtered (default = true)
211+
* Whether matches should be pre-filtered (default = true)
210212
*/
211213
preFilterMatches?: boolean;
214+
/**
215+
* Whether kernel completions should be shown first.
216+
*/
217+
kernelCompletionsFirst?: boolean;
212218
}
213219
export const defaultOptions: IOptions = {
214220
caseSensitive: true,
215221
includePerfectMatches: true,
216-
preFilterMatches: true
222+
preFilterMatches: true,
223+
kernelCompletionsFirst: false
217224
};
218225
}
219226

@@ -239,13 +246,22 @@ export class LSPCompleterModel extends GenericCompleterModel<MaybeCompletionItem
239246
a: ICompletionMatch<MaybeCompletionItem>,
240247
b: ICompletionMatch<MaybeCompletionItem>
241248
): number {
242-
const delta = a.score - b.score;
243-
if (delta !== 0) {
244-
return delta;
245-
}
246-
// solve ties using sortText
247-
248-
// note: locale compare is case-insensitive
249-
return (a.item.sortText ?? 'z').localeCompare(b.item.sortText ?? 'z');
249+
// TODO: take source order from provider ranks, upstream this code
250+
const sourceOrder = {
251+
LSP: 1,
252+
kernel: this.settings.kernelCompletionsFirst ? 0 : 2,
253+
context: 3
254+
};
255+
const aRank = a.item.source
256+
? sourceOrder[a.item.source as keyof typeof sourceOrder] ?? 4
257+
: 4;
258+
const bRank = b.item.source
259+
? sourceOrder[b.item.source as keyof typeof sourceOrder] ?? 4
260+
: 4;
261+
return (
262+
aRank - bRank ||
263+
(a.item.sortText ?? 'z').localeCompare(b.item.sortText ?? 'z') ||
264+
a.score - b.score
265+
);
250266
}
251267
}

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

Lines changed: 10 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -36,21 +36,17 @@ export class EnhancedContextCompleterProvider extends ContextCompleterProvider {
3636
super();
3737
}
3838

39-
private get _kernelCompletionsFirst(): boolean {
40-
return this.options.settings.composite.kernelCompletionsFirst;
41-
}
42-
4339
async fetch(
4440
request: CompletionHandler.IRequest,
4541
context: ICompletionContext
4642
): Promise<CompletionHandler.ICompletionItemsReply> {
4743
const result = await super.fetch(request, context);
48-
result.items = result.items.map(i => {
44+
result.items = result.items.map((item, order) => {
4945
return {
50-
...i,
51-
icon: this.iconFor(i.type ?? 'Text') ?? this.iconFor('Text'),
52-
type: i.type === '<unknown>' ? undefined : (i.type as string),
53-
sortText: this._kernelCompletionsFirst ? 'a' : 'z',
46+
...item,
47+
icon: this.iconFor(item.type ?? 'Text') ?? this.iconFor('Text'),
48+
type: item.type === '<unknown>' ? undefined : (item.type as string),
49+
sortText: String.fromCharCode(order),
5450
source: this.label
5551
};
5652
});
@@ -75,11 +71,11 @@ export class EnhancedKernelCompleterProvider extends KernelCompleterProvider {
7571
context: ICompletionContext
7672
): Promise<CompletionHandler.ICompletionItemsReply> {
7773
const result = await super.fetch(request, context);
78-
result.items = result.items.map(i => {
74+
result.items = result.items.map((item, order) => {
7975
return {
80-
...i,
81-
icon: this.iconFor(i.type ?? KernelKind) ?? this.iconFor(KernelKind),
82-
sortText: 'z',
76+
...item,
77+
icon: this.iconFor(item.type ?? KernelKind) ?? this.iconFor(KernelKind),
78+
sortText: String.fromCharCode(order),
8379
source: this.label
8480
};
8581
});
@@ -88,7 +84,7 @@ export class EnhancedKernelCompleterProvider extends KernelCompleterProvider {
8884

8985
async isApplicable(context: ICompletionContext): Promise<boolean> {
9086
// Note: this method logs errors instead of throwing to ensure we do not ever
91-
// break the upstream kernel completer, even if there is an error elsehwere.
87+
// break the upstream kernel completer, even if there is an error elsewhere.
9288
const upstream = await super.isApplicable(context);
9389

9490
if (upstream === false) {

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,13 +65,15 @@ export class CompletionProvider implements ICompletionProvider<CompletionItem> {
6565
const model = new LSPCompleterModel({
6666
caseSensitive: composite.caseSensitive,
6767
preFilterMatches: composite.preFilterMatches,
68-
includePerfectMatches: composite.includePerfectMatches
68+
includePerfectMatches: composite.includePerfectMatches,
69+
kernelCompletionsFirst: composite.kernelCompletionsFirst
6970
});
7071
this.options.settings.changed.connect(() => {
7172
const composite = this.options.settings.composite;
7273
model.settings.caseSensitive = composite.caseSensitive;
7374
model.settings.preFilterMatches = composite.preFilterMatches;
7475
model.settings.includePerfectMatches = composite.includePerfectMatches;
76+
model.settings.kernelCompletionsFirst = composite.kernelCompletionsFirst;
7577
});
7678
return model;
7779
};

0 commit comments

Comments
 (0)