From a96c52b21e7e590bbdd70b80896780a446fa2e8b Mon Sep 17 00:00:00 2001 From: Kristóf Marussy Date: Mon, 13 Dec 2021 02:07:04 +0100 Subject: build: separate module for frontend This allows us to simplify the webpack configuration and the gradle build scripts. --- .../frontend/src/xtext/ContentAssistService.ts | 219 +++++++++++++++++++++ 1 file changed, 219 insertions(+) create mode 100644 subprojects/frontend/src/xtext/ContentAssistService.ts (limited to 'subprojects/frontend/src/xtext/ContentAssistService.ts') diff --git a/subprojects/frontend/src/xtext/ContentAssistService.ts b/subprojects/frontend/src/xtext/ContentAssistService.ts new file mode 100644 index 00000000..8b872e06 --- /dev/null +++ b/subprojects/frontend/src/xtext/ContentAssistService.ts @@ -0,0 +1,219 @@ +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 type { UpdateService } from './UpdateService'; +import { getLogger } from '../utils/logger'; +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 | 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, + implicitCompletion: token.type.prop(implicitCompletion) || false, + text: state.sliceDoc(token.from, token.to), + }; +} + +function shouldCompleteImplicitly(token: IFoundToken | null, context: CompletionContext): boolean { + return token !== null + && 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; + } + return { + label: entry.proposal, + detail: entry.description, + info: entry.documentation, + type: entry.kind?.toLowerCase(), + boost, + }; +} + +export class ContentAssistService { + private readonly updateService: UpdateService; + + private lastCompletion: CompletionResult | null = null; + + constructor(updateService: UpdateService) { + this.updateService = updateService; + } + + onTransaction(transaction: Transaction): void { + if (this.shouldInvalidateCachedCompletion(transaction)) { + this.lastCompletion = null; + } + } + + async contentAssist(context: CompletionContext): Promise { + 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) { + 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)) { + log.trace('Returning cached completion result'); + // Postcondition of `shouldReturnCachedCompletion`: `lastCompletion !== null` + return { + ...this.lastCompletion as CompletionResult, + ...range, + }; + } + this.lastCompletion = null; + const entries = await this.updateService.fetchContentAssist({ + resource: this.updateService.resourceName, + serviceType: 'assist', + 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, + span: computeSpan(prefix, entries.length), + }; + return this.lastCompletion; + } + + private shouldReturnCachedCompletion( + token: { from: number, to: number, text: string } | null, + ): boolean { + if (token === null || this.lastCompletion === null) { + return false; + } + const { from, to, text } = token; + const { from: lastFrom, to: lastTo, span } = this.lastCompletion; + if (!lastTo) { + return true; + } + const [transformedFrom, transformedTo] = this.mapRangeInclusive(lastFrom, lastTo); + return from >= transformedFrom + && to <= transformedTo + && typeof span !== 'undefined' + && span.exec(text) !== null; + } + + private shouldInvalidateCachedCompletion(transaction: Transaction): boolean { + if (!transaction.docChanged || this.lastCompletion === null) { + return false; + } + const { from: lastFrom, to: lastTo } = this.lastCompletion; + if (!lastTo) { + return true; + } + const [transformedFrom, transformedTo] = this.mapRangeInclusive(lastFrom, lastTo); + 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]; + } +} -- cgit v1.2.3-54-g00ecf