Skip to content

Commit b7f04a3

Browse files
committed
Robustify handling of special cases in completion provider
Notably, we no longer adjust start/end position based on individual items because that was not predictable; instead we revert the logic and populate insert text as needed to match start/end. We only change end to equal start if there are absolutely no prefix matches, which is required because otherwise everything would be filtered out.
1 parent 730da0c commit b7f04a3

File tree

3 files changed

+53
-49
lines changed

3 files changed

+53
-49
lines changed

atest/05_Features/Completion.robot

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ Invalidates On Focus Loss
8585
Completer Should Not Suggest test
8686
Enter Cell Editor 1 line=2
8787

88-
Uses LSP Completions When Kernel Resoponse Times Out
88+
Uses LSP Completions When Kernel Response Times Out
8989
[Tags] requires:busy-indicator
9090
Configure JupyterLab Plugin {"kernelResponseTimeout": 1, "waitForBusyKernel": true}
9191
... plugin id=${COMPLETION PLUGIN ID}

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -256,7 +256,10 @@ export class LSPCompleterModel extends GenericCompleterModel<MaybeCompletionItem
256256

257257
protected harmoniseItem(item: CompletionHandler.ICompletionItem) {
258258
if ((item as any).self) {
259-
return (item as any).self;
259+
const self = (item as any).self;
260+
// reflect any changes made on copy
261+
self.insertText = item.insertText;
262+
return self;
260263
}
261264
return super.harmoniseItem(item);
262265
}

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

Lines changed: 48 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -322,53 +322,66 @@ export function transformLSPCompletions<T>(
322322
createCompletionItem: (kind: string, match: lsProtocol.CompletionItem) => T,
323323
console: ILSPLogConsole
324324
) {
325-
let prefix = token.value.slice(0, positionInToken + 1);
326-
let allNonPrefixed = true;
325+
let prefix = token.value.slice(0, positionInToken);
326+
let suffix = token.value.slice(positionInToken, token.value.length);
327327
let items: T[] = [];
328-
let prefixLength = prefix.length;
328+
// If there are no prefixes, we will just insert the text without replacing the token,
329+
// which is the case for example in R for `stats::<tab>` which returns module members
330+
// without `::` prefix.
331+
// If there are prefixes, we will replace the token so we may need to prepend/append to,
332+
// or otherwise modify the insert text of individual completion items.
333+
let anyPrefixed = false;
334+
329335
lspCompletionItems.forEach(match => {
330336
let kind = match.kind ? CompletionItemKind[match.kind] : '';
331337

332-
// Update prefix values
333338
let text = match.insertText ? match.insertText : match.label;
339+
let intendedText = match.insertText ? match.insertText : match.label;
334340

335-
// declare prefix presence if needed and update it
336-
if (text.toLowerCase().startsWith(prefix.toLowerCase())) {
337-
allNonPrefixed = false;
338-
if (prefix !== token.value) {
339-
if (text.toLowerCase().startsWith(token.value.toLowerCase())) {
340-
// given a completion insert text "display_table" and two test cases:
341-
// disp<tab>data → display_table<cursor>data
342-
// disp<tab>lay → display_table<cursor>
343-
// we have to adjust the prefix for the latter (otherwise we would get display_table<cursor>lay),
344-
// as we are constrained NOT to replace after the prefix (which would be "disp" otherwise)
345-
prefix = token.value;
346-
}
341+
if (intendedText.toLowerCase().startsWith(prefix.toLowerCase())) {
342+
anyPrefixed = true;
343+
}
344+
345+
// Add overlap with token prefix
346+
if (intendedText.toLowerCase().startsWith(token.value.toLowerCase())) {
347+
anyPrefixed = true;
348+
// remove overlap with prefix before expanding it
349+
if (intendedText.toLowerCase().startsWith(prefix.toLowerCase())) {
350+
text = text.substring(prefix.length, text.length);
351+
match.insertText = text;
347352
}
353+
text = token.value + text;
354+
match.insertText = text;
348355
}
349-
// add prefix if needed
350-
else if (token.type === 'String' && prefix.includes('/')) {
351-
// special case for path completion in strings, ensuring that:
352-
// '/Com<tab> → '/Completion.ipynb
353-
// when the returned insert text is `Completion.ipynb` (the token here is `'/Com`)
354-
// developed against pyls and pylsp server, may not work well in other cases
355356

357+
// special handling for paths
358+
if (token.type === 'String' && prefix.includes('/')) {
356359
const parts = stripQuotes(prefix).split('/');
357360
if (
358361
text.toLowerCase().startsWith(parts[parts.length - 1].toLowerCase())
359362
) {
360363
let pathPrefix = parts.slice(0, -1).join('/') + '/';
361-
const withStartingQuote =
364+
text =
362365
(prefix.startsWith("'") || prefix.startsWith('"') ? prefix[0] : '') +
363366
pathPrefix +
364367
text;
365-
match.insertText = withStartingQuote;
368+
match.insertText = text;
366369
// for label without quotes
367370
match.label = pathPrefix + match.label;
368-
if (prefix.endsWith("'") || prefix.endsWith('"')) {
369-
prefixLength = prefix.length - 1;
371+
anyPrefixed = true;
372+
}
373+
} else {
374+
// harmonise end to token
375+
if (text.toLowerCase().endsWith(suffix.toLowerCase())) {
376+
text = text.substring(0, text.length - suffix.length);
377+
match.insertText = text;
378+
} else if (token.type === 'String') {
379+
// special case for completion in strings to preserve the closing quote;
380+
// there is an issue that this gives opposing results in Notebook vs File editor
381+
// probably due to reconciliator logic
382+
if (suffix.startsWith("'") || suffix.startsWith('"')) {
383+
match.insertText = text + suffix[0];
370384
}
371-
allNonPrefixed = false;
372385
}
373386
}
374387

@@ -377,29 +390,17 @@ export function transformLSPCompletions<T>(
377390
items.push(completionItem);
378391
});
379392
console.debug('Transformed');
380-
// required to make the repetitive trigger characters like :: or ::: work for R with R languageserver,
381-
// see https://github.com/jupyter-lsp/jupyterlab-lsp/issues/436
382-
let prefixOffset = token.value.length;
383-
// completion of dictionaries for Python with jedi-language-server was
384-
// causing an issue for dic['<tab>'] case; to avoid this let's make
385-
// sure that prefix.length >= prefix.offset
386-
if (allNonPrefixed && prefixOffset > prefixLength) {
387-
prefixOffset = prefixLength;
393+
394+
let start = token.offset;
395+
let end = token.offset + token.value.length;
396+
if (!anyPrefixed) {
397+
start = end;
388398
}
389399

390400
let response = {
391-
// note in the ContextCompleter it was:
392-
// start: token.offset,
393-
// end: token.offset + token.value.length,
394-
// which does not work with "from statistics import <tab>" as the last token ends at "t" of "import",
395-
// so the completer would append "mean" as "from statistics importmean" (without space!);
396-
// (in such a case the typedCharacters is undefined as we are out of range)
397-
// a different workaround would be to prepend the token.value prefix:
398-
// text = token.value + text;
399-
// but it did not work for "from statistics <tab>" and lead to "from statisticsimport" (no space)
400-
start: token.offset + (allNonPrefixed ? prefixOffset : 0),
401-
end: token.offset + prefixLength,
402-
items: items,
401+
start,
402+
end,
403+
items,
403404
source: 'LSP'
404405
};
405406
if (response.start > response.end) {

0 commit comments

Comments
 (0)