From e36be07141300b3300ce7347e9ee089c1a050da7 Mon Sep 17 00:00:00 2001 From: Kristóf Marussy Date: Wed, 3 Nov 2021 01:55:19 +0100 Subject: fix(web): fix autocomplete prefix behavior Always try to complete the current token if it is a terminal (e.g., true, false, unknown, and error after a : or an = sign). Autocomplete still only starts without being explicitly invoked if there is a QualifiedName to complete. Discard completions with a shorter prefix than the current token, because they would be filtered out by CodeMirror anyways. --- .../src/main/js/xtext/ContentAssistService.ts | 105 +++++++++++++++------ 1 file changed, 77 insertions(+), 28 deletions(-) (limited to 'language-web/src/main') diff --git a/language-web/src/main/js/xtext/ContentAssistService.ts b/language-web/src/main/js/xtext/ContentAssistService.ts index f085c5b1..65381b21 100644 --- a/language-web/src/main/js/xtext/ContentAssistService.ts +++ b/language-web/src/main/js/xtext/ContentAssistService.ts @@ -3,6 +3,7 @@ import type { CompletionContext, CompletionResult, } from '@codemirror/autocomplete'; +import { syntaxTree } from '@codemirror/language'; import type { Transaction } from '@codemirror/state'; import escapeStringRegexp from 'escape-string-regexp'; @@ -10,16 +11,69 @@ import type { UpdateService } from './UpdateService'; import { getLogger } from '../utils/logger'; import type { IContentAssistEntry } from './xtextServiceResults'; +const IMPLICIT_COMPLETION_TOKENS = [ + 'QualifiedName', + 'true', + 'false', + 'unknown', + 'error', +]; + const PROPOSALS_LIMIT = 1000; const IDENTIFIER_REGEXP_STR = '[a-zA-Z0-9_]*'; -const HIGH_PRIORITY_KEYWORDS = ['<->']; - -const QUALIFIED_NAME_SEPARATOR_REGEXP = /::/g; +const HIGH_PRIORITY_KEYWORDS = ['<->', '~>']; const log = getLogger('xtext.ContentAssistService'); +interface IFoundToken { + from: number; + + to: number; + + name: string; + + text: string; +} + +function findToken({ pos, state }: CompletionContext): IFoundToken | null { + const token = syntaxTree(state).resolveInner(pos, -1); + if (token === null) { + return null; + } + if (token.firstChild !== null) { + // We only autocomplete terminal nodes. If the current node is nonterminal, + // returning `null` makes us autocomplete with the empty prefix instead. + return null; + } + return { + from: token.from, + to: token.to, + name: token.name, + text: state.sliceDoc(token.from, token.to), + }; +} + +function shouldCompleteImplicitly(token: IFoundToken | null, context: CompletionContext): boolean { + return token !== null + && IMPLICIT_COMPLETION_TOKENS.includes(token.name) + && context.pos - token.from >= 2; +} + +function computeSpan(prefix: string, entryCount: number): RegExp { + const escapedPrefix = escapeStringRegexp(prefix); + if (entryCount < PROPOSALS_LIMIT) { + // Proposals with the current prefix fit the proposals limit. + // We can filter client side as long as the current prefix is preserved. + return new RegExp(`^${escapedPrefix}${IDENTIFIER_REGEXP_STR}$`); + } + // The current prefix overflows the proposals limits, + // so we have to fetch the completions again on the next keypress. + // Hopefully, it'll return a shorter list and we'll be able to filter client side. + return new RegExp(`^${escapedPrefix}$`); +} + function createCompletion(entry: IContentAssistEntry): Completion { let boost; switch (entry.kind) { @@ -33,7 +87,7 @@ function createCompletion(entry: IContentAssistEntry): Completion { break; default: { // Penalize qualified names (vs available unqualified names). - const extraSegments = entry.proposal.match(QUALIFIED_NAME_SEPARATOR_REGEXP)?.length || 0; + const extraSegments = entry.proposal.match(/::/g)?.length || 0; boost = Math.max(-5 * extraSegments, -50); } break; @@ -47,19 +101,6 @@ function createCompletion(entry: IContentAssistEntry): Completion { }; } -function computeSpan(prefix: string, entryCount: number) { - const escapedPrefix = escapeStringRegexp(prefix); - if (entryCount < PROPOSALS_LIMIT) { - // Proposals with the current prefix fit the proposals limit. - // We can filter client side as long as the current prefix is preserved. - return new RegExp(`^${escapedPrefix}${IDENTIFIER_REGEXP_STR}$`); - } - // The current prefix overflows the proposals limits, - // so we have to fetch the completions again on the next keypress. - // Hopefully, it'll return a shorter list and we'll be able to filter client side. - return new RegExp(`^${escapedPrefix}$`); -} - export class ContentAssistService { private readonly updateService: UpdateService; @@ -76,16 +117,16 @@ export class ContentAssistService { } async contentAssist(context: CompletionContext): Promise { - const tokenBefore = context.tokenBefore(['QualifiedName']); + const tokenBefore = findToken(context); + if (!context.explicit && !shouldCompleteImplicitly(tokenBefore, context)) { + return { + from: context.pos, + options: [], + }; + } let range: { from: number, to: number }; let prefix = ''; if (tokenBefore === null) { - if (!context.explicit) { - return { - from: context.pos, - options: [], - }; - } range = { from: context.pos, to: context.pos, @@ -124,7 +165,12 @@ export class ContentAssistService { } const options: Completion[] = []; entries.forEach((entry) => { - options.push(createCompletion(entry)); + if (prefix === entry.prefix) { + // Xtext will generate completions that do not complete the current token, + // e.g., `(` after trying to complete an indetifier, + // but we ignore those, since CodeMirror won't filter for them anyways. + options.push(createCompletion(entry)); + } }); log.debug('Fetched', options.length, 'completions from server'); this.lastCompletion = { @@ -137,7 +183,7 @@ export class ContentAssistService { private shouldReturnCachedCompletion( token: { from: number, to: number, text: string } | null, - ) { + ): boolean { if (token === null || this.lastCompletion === null) { return false; } @@ -147,10 +193,13 @@ export class ContentAssistService { return true; } const [transformedFrom, transformedTo] = this.mapRangeInclusive(lastFrom, lastTo); - return from >= transformedFrom && to <= transformedTo && span && span.exec(text); + return from >= transformedFrom + && to <= transformedTo + && typeof span !== 'undefined' + && span.exec(text) !== null; } - private shouldInvalidateCachedCompletion(transaction: Transaction) { + private shouldInvalidateCachedCompletion(transaction: Transaction): boolean { if (!transaction.docChanged || this.lastCompletion === null) { return false; } -- cgit v1.2.3-70-g09d2