From cc6cc0336091f838d27d66267004675ee96e1a40 Mon Sep 17 00:00:00 2001 From: Kristóf Marussy Date: Sat, 30 Oct 2021 02:26:43 +0200 Subject: feat(web): add xtext content assist --- language-web/src/main/js/editor/EditorParent.ts | 12 +- language-web/src/main/js/editor/EditorStore.ts | 7 +- language-web/src/main/js/editor/XtextClient.ts | 307 +++++++++++++++++---- language-web/src/main/js/editor/xtextMessages.ts | 8 +- .../src/main/js/editor/xtextServiceResults.ts | 138 ++++++++- 5 files changed, 402 insertions(+), 70 deletions(-) diff --git a/language-web/src/main/js/editor/EditorParent.ts b/language-web/src/main/js/editor/EditorParent.ts index a2f6c266..0a25214b 100644 --- a/language-web/src/main/js/editor/EditorParent.ts +++ b/language-web/src/main/js/editor/EditorParent.ts @@ -5,13 +5,15 @@ export const EditorParent = styled('div')(({ theme }) => ({ '&, .cm-editor': { height: '100%', }, - '.cm-scroller': { + '.cm-scroller, .cm-tooltip-autocomplete, .cm-completionLabel, .cm-completionDetail': { fontSize: 16, fontFamily: '"JetBrains MonoVariable", "JetBrains Mono", monospace', fontFeatureSettings: '"liga", "calt"', fontWeight: 400, letterSpacing: 0, textRendering: 'optimizeLegibility', + }, + '.cm-scroller': { color: theme.palette.text.secondary, }, '.cm-gutters': { @@ -59,7 +61,7 @@ export const EditorParent = styled('div')(({ theme }) => ({ color: theme.palette.text.secondary, }, '.cmt-comment': { - fontVariant: 'italic', + fontStyle: 'italic', color: theme.palette.text.disabled, }, '.cmt-number': { @@ -77,4 +79,10 @@ export const EditorParent = styled('div')(({ theme }) => ({ '.cmt-variableName': { color: '#c8ae9d', }, + '.cm-completionIcon': { + width: 16, + padding: 0, + marginRight: '0.5em', + textAlign: 'center', + }, })); diff --git a/language-web/src/main/js/editor/EditorStore.ts b/language-web/src/main/js/editor/EditorStore.ts index 32fe6fd1..dcc69fd1 100644 --- a/language-web/src/main/js/editor/EditorStore.ts +++ b/language-web/src/main/js/editor/EditorStore.ts @@ -79,7 +79,12 @@ export class EditorStore { this.state = EditorState.create({ doc: initialValue, extensions: [ - autocompletion(), + autocompletion({ + activateOnTyping: true, + override: [ + (context) => this.client.contentAssist(context), + ], + }), classHighlightStyle.extension, closeBrackets(), bracketMatching(), diff --git a/language-web/src/main/js/editor/XtextClient.ts b/language-web/src/main/js/editor/XtextClient.ts index 39458e93..6f789fb7 100644 --- a/language-web/src/main/js/editor/XtextClient.ts +++ b/language-web/src/main/js/editor/XtextClient.ts @@ -1,3 +1,8 @@ +import { + Completion, + CompletionContext, + CompletionResult, +} from '@codemirror/autocomplete'; import type { Diagnostic } from '@codemirror/lint'; import { ChangeDesc, @@ -10,21 +15,20 @@ import type { EditorStore } from './EditorStore'; import { getLogger } from '../logging'; import { Timer } from '../utils/Timer'; import { + IContentAssistEntry, + isContentAssistResult, isDocumentStateResult, - isServiceConflictResult, + isInvalidStateIdConflictResult, isValidationResult, } from './xtextServiceResults'; import { XtextWebSocketClient } from './XtextWebSocketClient'; +import { PendingTask } from '../utils/PendingTask'; -const UPDATE_TIMEOUT_MS = 300; - -const log = getLogger('XtextClient'); +const UPDATE_TIMEOUT_MS = 500; -enum UpdateAction { - ForceReconnect, +const WAIT_FOR_UPDATE_TIMEOUT_MS = 1000; - FullTextUpdate, -} +const log = getLogger('XtextClient'); export class XtextClient { resourceName: string; @@ -37,6 +41,10 @@ export class XtextClient { dirtyChanges: ChangeDesc; + lastCompletion: CompletionResult | null = null; + + updateListeners: PendingTask[] = []; + updateTimer = new Timer(() => { this.handleUpdate(); }, UPDATE_TIMEOUT_MS); @@ -50,6 +58,7 @@ export class XtextClient { this.dirtyChanges = this.newEmptyChangeDesc(); this.webSocketClient = new XtextWebSocketClient( async () => { + this.xtextStateId = null; await this.updateFullText(); }, async (resource, stateId, service, push) => { @@ -61,6 +70,10 @@ export class XtextClient { onTransaction(transaction: Transaction): void { const { changes } = transaction; if (!changes.empty) { + if (this.shouldInvalidateCachedCompletion(transaction)) { + log.trace('Invalidating cached completions'); + this.lastCompletion = null; + } this.dirtyChanges = this.dirtyChanges.composeDesc(changes.desc); this.updateTimer.reschedule(); } @@ -110,18 +123,15 @@ export class XtextClient { } private computeChangesSinceLastUpdate() { - if (this.pendingUpdate === null) { - return this.dirtyChanges; - } - return this.pendingUpdate.composeDesc(this.dirtyChanges); + return this.pendingUpdate?.composeDesc(this.dirtyChanges) || this.dirtyChanges; } private handleUpdate() { if (!this.webSocketClient.isOpen || this.dirtyChanges.empty) { return; } - if (!this.pendingUpdate) { - this.updateDeltaText().catch((error) => { + if (this.pendingUpdate === null) { + this.update().catch((error) => { log.error('Unexpected error during scheduled update', error); }); } @@ -134,56 +144,201 @@ export class XtextClient { } private async updateFullText() { + await this.withUpdate(() => this.doUpdateFullText()); + } + + private async doUpdateFullText(): Promise<[string, void]> { + const result = await this.webSocketClient.send({ + resource: this.resourceName, + serviceType: 'update', + fullText: this.store.state.doc.sliceString(0), + }); + if (isDocumentStateResult(result)) { + return [result.stateId, undefined]; + } + log.error('Unexpected full text update result:', result); + throw new Error('Full text update failed'); + } + + async update(): Promise { + await this.prepareForDeltaUpdate(); + const delta = this.computeDelta(); + if (delta === null) { + return; + } + log.trace('Editor delta', delta); await this.withUpdate(async () => { const result = await this.webSocketClient.send({ resource: this.resourceName, serviceType: 'update', - fullText: this.store.state.doc.sliceString(0), + requiredStateId: this.xtextStateId, + ...delta, }); if (isDocumentStateResult(result)) { - return result.stateId; + return [result.stateId, undefined]; } - if (isServiceConflictResult(result)) { - log.error('Full text update conflict:', result.conflict); - if (result.conflict === 'canceled') { - return UpdateAction.FullTextUpdate; - } - return UpdateAction.ForceReconnect; + if (isInvalidStateIdConflictResult(result)) { + return this.doFallbackToUpdateFullText(); } - log.error('Unexpected full text update result:', result); - return UpdateAction.ForceReconnect; + log.error('Unexpected delta text update result:', result); + throw new Error('Delta text update failed'); }); } - private async updateDeltaText() { - if (this.xtextStateId === null) { - await this.updateFullText(); - return; + private doFallbackToUpdateFullText() { + if (this.pendingUpdate === null) { + throw new Error('Only a pending update can be extended'); + } + log.warn('Delta update failed, performing full text update'); + this.xtextStateId = null; + this.pendingUpdate = this.pendingUpdate.composeDesc(this.dirtyChanges); + this.dirtyChanges = this.newEmptyChangeDesc(); + return this.doUpdateFullText(); + } + + async contentAssist(context: CompletionContext): Promise { + const tokenBefore = context.tokenBefore(['QualifiedName']); + if (tokenBefore === null && !context.explicit) { + return { + from: context.pos, + options: [], + }; + } + const range = { + from: tokenBefore?.from || context.pos, + to: tokenBefore?.to || context.pos, + }; + if (this.shouldReturnCachedCompletion(tokenBefore)) { + log.trace('Returning cached completion result'); + // Postcondition of `shouldReturnCachedCompletion`: `lastCompletion !== null` + return { + ...this.lastCompletion as CompletionResult, + ...range, + }; + } + const entries = await this.fetchContentAssist(context); + if (context.aborted) { + return { + ...range, + options: [], + }; + } + const options: Completion[] = []; + entries.forEach((entry) => { + options.push({ + label: entry.proposal, + detail: entry.description, + info: entry.documentation, + type: entry.kind?.toLowerCase(), + boost: entry.kind === 'KEYWORD' ? -90 : 0, + }); + }); + log.debug('Fetched', options.length, 'completions from server'); + this.lastCompletion = { + ...range, + options, + span: /[a-zA-Z0-9_:]/, + }; + return this.lastCompletion; + } + + private shouldReturnCachedCompletion( + token: { from: number, to: number, text: string } | null, + ) { + 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 = this.dirtyChanges.mapPos(lastFrom); + const transformedTo = this.dirtyChanges.mapPos(lastTo, 1); + return from >= transformedFrom && to <= transformedTo && span && span.exec(text); + } + + private shouldInvalidateCachedCompletion(transaction: Transaction) { + if (this.lastCompletion === null) { + return false; } + const { from: lastFrom, to: lastTo } = this.lastCompletion; + if (!lastTo) { + return true; + } + const transformedFrom = this.dirtyChanges.mapPos(lastFrom); + const transformedTo = this.dirtyChanges.mapPos(lastTo, 1); + let invalidate = false; + transaction.changes.iterChangedRanges((fromA, toA) => { + if (fromA < transformedFrom || toA > transformedTo) { + invalidate = true; + } + }); + return invalidate; + } + + private async fetchContentAssist(context: CompletionContext) { + await this.prepareForDeltaUpdate(); const delta = this.computeDelta(); + if (delta === null) { + // Poscondition of `prepareForDeltaUpdate`: `xtextStateId !== null` + return this.doFetchContentAssist(context, this.xtextStateId as string); + } log.trace('Editor delta', delta); - await this.withUpdate(async () => { + return await this.withUpdate(async () => { const result = await this.webSocketClient.send({ - resource: this.resourceName, - serviceType: 'update', requiredStateId: this.xtextStateId, + ...this.computeContentAssistParams(context), ...delta, }); - if (isDocumentStateResult(result)) { - return result.stateId; + if (isContentAssistResult(result)) { + return [result.stateId, result.entries]; } - if (isServiceConflictResult(result)) { - log.error('Delta text update conflict:', result.conflict); - return UpdateAction.FullTextUpdate; + if (isInvalidStateIdConflictResult(result)) { + const [newStateId] = await this.doFallbackToUpdateFullText(); + if (context.aborted) { + return [newStateId, [] as IContentAssistEntry[]]; + } + const entries = await this.doFetchContentAssist(context, newStateId); + return [newStateId, entries]; } - log.error('Unexpected delta text update result:', result); - return UpdateAction.ForceReconnect; + log.error('Unextpected content assist result with delta update', result); + throw new Error('Unexpexted content assist result with delta update'); + }); + } + + private async doFetchContentAssist(context: CompletionContext, expectedStateId: string) { + const result = await this.webSocketClient.send({ + requiredStateId: expectedStateId, + ...this.computeContentAssistParams(context), }); + if (isContentAssistResult(result) && result.stateId === expectedStateId) { + return result.entries; + } + log.error('Unexpected content assist result', result); + throw new Error('Unexpected content assist result'); + } + + private computeContentAssistParams(context: CompletionContext) { + const tokenBefore = context.tokenBefore(['QualifiedName']); + let selection = {}; + if (tokenBefore !== null) { + selection = { + selectionStart: tokenBefore.from, + selectionEnd: tokenBefore.to, + }; + } + return { + resource: this.resourceName, + serviceType: 'assist', + caretOffset: tokenBefore?.from || context.pos, + ...selection, + }; } private computeDelta() { if (this.dirtyChanges.empty) { - return {}; + return null; } let minFromA = Number.MAX_SAFE_INTEGER; let maxToA = 0; @@ -202,34 +357,68 @@ export class XtextClient { }; } - private async withUpdate(callback: () => Promise) { + private async withUpdate(callback: () => Promise<[string, T]>): Promise { if (this.pendingUpdate !== null) { - log.error('Another update is pending, will not perform update'); - return; + throw new Error('Another update is pending, will not perform update'); } this.pendingUpdate = this.dirtyChanges; this.dirtyChanges = this.newEmptyChangeDesc(); - let newStateId: string | UpdateAction = UpdateAction.ForceReconnect; + let newStateId: string | null = null; try { - newStateId = await callback(); - } catch (error) { - log.error('Error while updating state', error); - } finally { - if (typeof newStateId === 'string') { - this.xtextStateId = newStateId; - this.pendingUpdate = null; + let result: T; + [newStateId, result] = await callback(); + this.xtextStateId = newStateId; + this.pendingUpdate = null; + // Copy `updateListeners` so that we don't get into a race condition + // if one of the listeners adds another listener. + const listeners = this.updateListeners; + this.updateListeners = []; + listeners.forEach((listener) => { + listener.resolve(); + }); + return result; + } catch (e) { + log.error('Error while update', e); + if (this.pendingUpdate === null) { + log.error('pendingUpdate was cleared during update'); } else { this.dirtyChanges = this.pendingUpdate.composeDesc(this.dirtyChanges); - this.pendingUpdate = null; - switch (newStateId) { - case UpdateAction.ForceReconnect: - this.webSocketClient.forceReconnectOnError(); - break; - case UpdateAction.FullTextUpdate: - await this.updateFullText(); - break; - } } + this.pendingUpdate = null; + this.webSocketClient.forceReconnectOnError(); + const listeners = this.updateListeners; + this.updateListeners = []; + listeners.forEach((listener) => { + listener.reject(e); + }); + throw e; + } + } + + private async prepareForDeltaUpdate() { + if (this.pendingUpdate === null) { + if (this.xtextStateId === null) { + return; + } + await this.updateFullText(); + } + let nowMs = Date.now(); + const endMs = nowMs + WAIT_FOR_UPDATE_TIMEOUT_MS; + while (this.pendingUpdate !== null && nowMs < endMs) { + const timeoutMs = endMs - nowMs; + const promise = new Promise((resolve, reject) => { + const task = new PendingTask(resolve, reject, timeoutMs); + this.updateListeners.push(task); + }); + // We must keep waiting uptil the update has completed, + // so the tasks can't be started in parallel. + // eslint-disable-next-line no-await-in-loop + await promise; + nowMs = Date.now(); + } + if (this.pendingUpdate !== null || this.xtextStateId === null) { + log.error('No successful update in', WAIT_FOR_UPDATE_TIMEOUT_MS, 'ms'); + throw new Error('Failed to wait for successful update'); } } } diff --git a/language-web/src/main/js/editor/xtextMessages.ts b/language-web/src/main/js/editor/xtextMessages.ts index be3125e6..68737958 100644 --- a/language-web/src/main/js/editor/xtextMessages.ts +++ b/language-web/src/main/js/editor/xtextMessages.ts @@ -21,6 +21,11 @@ export const VALID_XTEXT_WEB_ERROR_KINDS = ['request', 'server'] as const; export type XtextWebErrorKind = typeof VALID_XTEXT_WEB_ERROR_KINDS[number]; +export function isXtextWebErrorKind(value: unknown): value is XtextWebErrorKind { + return typeof value === 'string' + && VALID_XTEXT_WEB_ERROR_KINDS.includes(value as XtextWebErrorKind); +} + export interface IXtextWebErrorResponse { id: string; @@ -33,8 +38,7 @@ export function isErrorResponse(response: unknown): response is IXtextWebErrorRe const errorResponse = response as IXtextWebErrorResponse; return typeof errorResponse === 'object' && typeof errorResponse.id === 'string' - && typeof errorResponse.error === 'string' - && VALID_XTEXT_WEB_ERROR_KINDS.includes(errorResponse.error) + && isXtextWebErrorKind(errorResponse.error) && typeof errorResponse.message === 'string'; } diff --git a/language-web/src/main/js/editor/xtextServiceResults.ts b/language-web/src/main/js/editor/xtextServiceResults.ts index 8a4afa40..6c3d9daf 100644 --- a/language-web/src/main/js/editor/xtextServiceResults.ts +++ b/language-web/src/main/js/editor/xtextServiceResults.ts @@ -22,20 +22,32 @@ export const VALID_CONFLICTS = ['invalidStateId', 'canceled'] as const; export type Conflict = typeof VALID_CONFLICTS[number]; +export function isConflict(value: unknown): value is Conflict { + return typeof value === 'string' && VALID_CONFLICTS.includes(value as Conflict); +} + export interface IServiceConflictResult { conflict: Conflict; } export function isServiceConflictResult(result: unknown): result is IServiceConflictResult { const serviceConflictResult = result as IServiceConflictResult; - return typeof serviceConflictResult.conflict === 'string' - && VALID_CONFLICTS.includes(serviceConflictResult.conflict); + return typeof serviceConflictResult === 'object' + && isConflict(serviceConflictResult.conflict); +} + +export function isInvalidStateIdConflictResult(result: unknown): boolean { + return isServiceConflictResult(result) && result.conflict === 'invalidStateId'; } export const VALID_SEVERITIES = ['error', 'warning', 'info', 'ignore'] as const; export type Severity = typeof VALID_SEVERITIES[number]; +export function isSeverity(value: unknown): value is Severity { + return typeof value === 'string' && VALID_SEVERITIES.includes(value as Severity); +} + export interface IIssue { description: string; @@ -54,8 +66,7 @@ export function isIssue(value: unknown): value is IIssue { const issue = value as IIssue; return typeof issue === 'object' && typeof issue.description === 'string' - && typeof issue.severity === 'string' - && VALID_SEVERITIES.includes(issue.severity) + && isSeverity(issue.severity) && typeof issue.line === 'number' && typeof issue.column === 'number' && typeof issue.offset === 'number' @@ -66,9 +77,124 @@ export interface IValidationResult { issues: IIssue[]; } +function isArrayOfType(value: unknown, check: (entry: unknown) => entry is T): value is T[] { + return Array.isArray(value) && (value as T[]).every(check); +} + export function isValidationResult(result: unknown): result is IValidationResult { const validationResult = result as IValidationResult; return typeof validationResult === 'object' - && Array.isArray(validationResult.issues) - && validationResult.issues.every(isIssue); + && isArrayOfType(validationResult.issues, isIssue); +} + +export interface IReplaceRegion { + offset: number; + + length: number; + + text: string; +} + +export function isReplaceRegion(value: unknown): value is IReplaceRegion { + const replaceRegion = value as IReplaceRegion; + return typeof replaceRegion === 'object' + && typeof replaceRegion.offset === 'number' + && typeof replaceRegion.length === 'number' + && typeof replaceRegion.text === 'string'; +} + +export interface ITextRegion { + offset: number; + + length: number; +} + +export function isTextRegion(value: unknown): value is ITextRegion { + const textRegion = value as ITextRegion; + return typeof textRegion === 'object' + && typeof textRegion.offset === 'number' + && typeof textRegion.length === 'number'; +} + +export const VALID_XTEXT_CONTENT_ASSIST_ENTRY_KINDS = [ + 'TEXT', + 'METHOD', + 'FUNCTION', + 'CONSTRUCTOR', + 'FIELD', + 'VARIABLE', + 'CLASS', + 'INTERFACE', + 'MODULE', + 'PROPERTY', + 'UNIT', + 'VALUE', + 'ENUM', + 'KEYWORD', + 'SNIPPET', + 'COLOR', + 'FILE', + 'REFERENCE', + 'UNKNOWN', +] as const; + +export type XtextContentAssistEntryKind = typeof VALID_XTEXT_CONTENT_ASSIST_ENTRY_KINDS[number]; + +export function isXtextContentAssistEntryKind( + value: unknown, +): value is XtextContentAssistEntryKind { + return typeof value === 'string' + && VALID_XTEXT_CONTENT_ASSIST_ENTRY_KINDS.includes(value as XtextContentAssistEntryKind); +} + +export interface IContentAssistEntry { + prefix: string; + + proposal: string; + + label?: string; + + description?: string; + + documentation?: string; + + escapePosition?: number; + + textReplacements: IReplaceRegion[]; + + editPositions: ITextRegion[]; + + kind: XtextContentAssistEntryKind | string; +} + +function isStringOrUndefined(value: unknown): value is string | undefined { + return typeof value === 'string' || typeof value === 'undefined'; +} + +function isNumberOrUndefined(value: unknown): value is number | undefined { + return typeof value === 'number' || typeof value === 'undefined'; +} + +export function isContentAssistEntry(value: unknown): value is IContentAssistEntry { + const entry = value as IContentAssistEntry; + return typeof entry === 'object' + && typeof entry.prefix === 'string' + && typeof entry.proposal === 'string' + && isStringOrUndefined(entry.label) + && isStringOrUndefined(entry.description) + && isStringOrUndefined(entry.documentation) + && isNumberOrUndefined(entry.escapePosition) + && isArrayOfType(entry.textReplacements, isReplaceRegion) + && isArrayOfType(entry.editPositions, isTextRegion) + && typeof entry.kind === 'string'; +} + +export interface IContentAssistResult extends IDocumentStateResult { + entries: IContentAssistEntry[]; +} + +export function isContentAssistResult(result: unknown): result is IContentAssistResult { + const contentAssistResult = result as IContentAssistResult; + return isDocumentStateResult(result) + && isArrayOfType(contentAssistResult.entries, isContentAssistEntry); } -- cgit v1.2.3-70-g09d2