/* * SPDX-FileCopyrightText: 2021-2023 The Refinery Authors * * SPDX-License-Identifier: EPL-2.0 */ import type { Completion, CompletionContext, CompletionResult, } from '@codemirror/autocomplete'; import { syntaxTree } from '@codemirror/language'; import type { Transaction } from '@codemirror/state'; import escapeStringRegexp from 'escape-string-regexp'; import { implicitCompletion } from '../language/props'; import getLogger from '../utils/getLogger'; import type UpdateService from './UpdateService'; import type { ContentAssistEntry } from './xtextServiceResults'; const PROPOSALS_LIMIT = 1000; const IDENTIFIER_REGEXP_STR = '[a-zA-Z0-9_]*'; const HIGH_PRIORITY_KEYWORDS = ['<->', '->', '==>']; const log = getLogger('xtext.ContentAssistService'); interface IFoundToken { from: number; to: number; implicitCompletion: boolean; text: string; } function findToken({ pos, state }: CompletionContext): IFoundToken | undefined { const token = syntaxTree(state).resolveInner(pos, -1); const { from } = token; if (from > pos) { // We haven't found the token we want to complete. // Complete with an empty prefix from `pos` instead. // The other `return undefined;` lines also handle this condition. return undefined; } // We look at the text at the beginning of the token. // For QualifiedName tokens right before a comment, this may be a comment token. const endIndex = token.firstChild?.from ?? token.to; if (pos > endIndex) { return undefined; } const text = state.sliceDoc(from, endIndex).trimEnd(); // Due to parser error recovery, we may get spurious whitespace // at the end of the token. const to = from + text.length; if (to > endIndex) { return undefined; } if (from > pos || endIndex < pos) { // We haven't found the token we want to complete. // Complete with an empty prefix from `pos` instead. return undefined; } return { from, to, implicitCompletion: token.type.prop(implicitCompletion) || false, text, }; } function shouldCompleteImplicitly( token: IFoundToken | undefined, context: CompletionContext, ): boolean { return ( token !== undefined && token.implicitCompletion && 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: ContentAssistEntry): Completion { let boost: number; switch (entry.kind) { case 'KEYWORD': // Some hard-to-type operators should be on top. boost = HIGH_PRIORITY_KEYWORDS.includes(entry.proposal) ? 10 : -99; break; case 'TEXT': case 'SNIPPET': boost = -90; break; default: { // Penalize qualified names (vs available unqualified names). const extraSegments = entry.proposal.match(/::/g)?.length || 0; boost = Math.max(-5 * extraSegments, -50); } break; } const completion: Completion = { label: entry.proposal, type: entry.kind?.toLowerCase(), boost, }; if (entry.documentation !== undefined) { completion.info = entry.documentation; } if (entry.description !== undefined) { completion.detail = entry.description; } return completion; } export default class ContentAssistService { private lastCompletion: CompletionResult | undefined; constructor(private readonly updateService: UpdateService) {} onTransaction(transaction: Transaction): void { if (this.shouldInvalidateCachedCompletion(transaction)) { this.lastCompletion = undefined; } } async contentAssist(context: CompletionContext): Promise { if (!this.updateService.opened) { this.lastCompletion = undefined; return { from: context.pos, options: [], }; } 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 === undefined) { range = { from: context.pos, to: context.pos, }; prefix = ''; } else { range = { from: tokenBefore.from, to: tokenBefore.to, }; const prefixLength = context.pos - tokenBefore.from; if (prefixLength > 0) { prefix = tokenBefore.text.substring(0, context.pos - tokenBefore.from); } } if (!context.explicit && this.shouldReturnCachedCompletion(tokenBefore)) { if (this.lastCompletion === undefined) { throw new Error( 'There is no cached completion, but we want to return it', ); } log.trace('Returning cached completion result'); return { ...this.lastCompletion, ...range, }; } this.lastCompletion = undefined; const entries = await this.updateService.fetchContentAssist( { caretOffset: context.pos, proposalsLimit: PROPOSALS_LIMIT, }, context, ); if (context.aborted) { return { ...range, options: [], }; } const options: Completion[] = []; entries.forEach((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 = { ...range, options, validFor: computeSpan(prefix, entries.length), }; return this.lastCompletion; } private shouldReturnCachedCompletion( token: { from: number; to: number; text: string } | undefined, ): boolean { if (token === undefined || this.lastCompletion === undefined) { return false; } const { from, to, text } = token; const { from: lastFrom, to: lastTo, validFor } = this.lastCompletion; if (!lastTo) { return true; } const [transformedFrom, transformedTo] = this.mapRangeInclusive( lastFrom, lastTo, ); return ( from >= transformedFrom && to <= transformedTo && validFor instanceof RegExp && validFor.exec(text) !== null ); } private shouldInvalidateCachedCompletion(transaction: Transaction): boolean { if (!transaction.docChanged || this.lastCompletion === undefined) { return false; } const { from: lastFrom, to: lastTo } = this.lastCompletion; if (lastTo === undefined) { return true; } let transformedFrom: number; let transformedTo: number; try { [transformedFrom, transformedTo] = this.mapRangeInclusive( lastFrom, lastTo, ); } catch (error) { if (error instanceof RangeError) { log.debug('Invalidating cache due to invalid range', error); return true; } throw error; } let invalidate = false; transaction.changes.iterChangedRanges((fromA, toA) => { if (fromA < transformedFrom || toA > transformedTo) { invalidate = true; } }); return invalidate; } private mapRangeInclusive( lastFrom: number, lastTo: number, ): [number, number] { const changes = this.updateService.computeChangesSinceLastUpdate(); const transformedFrom = changes.mapPos(lastFrom); const transformedTo = changes.mapPos(lastTo, 1); return [transformedFrom, transformedTo]; } }