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 +++++++++++++ .../frontend/src/xtext/HighlightingService.ts | 37 +++ .../frontend/src/xtext/OccurrencesService.ts | 127 +++++++ subprojects/frontend/src/xtext/UpdateService.ts | 363 +++++++++++++++++++++ .../frontend/src/xtext/ValidationService.ts | 39 +++ subprojects/frontend/src/xtext/XtextClient.ts | 86 +++++ .../frontend/src/xtext/XtextWebSocketClient.ts | 362 ++++++++++++++++++++ subprojects/frontend/src/xtext/xtextMessages.ts | 40 +++ .../frontend/src/xtext/xtextServiceResults.ts | 112 +++++++ 9 files changed, 1385 insertions(+) create mode 100644 subprojects/frontend/src/xtext/ContentAssistService.ts create mode 100644 subprojects/frontend/src/xtext/HighlightingService.ts create mode 100644 subprojects/frontend/src/xtext/OccurrencesService.ts create mode 100644 subprojects/frontend/src/xtext/UpdateService.ts create mode 100644 subprojects/frontend/src/xtext/ValidationService.ts create mode 100644 subprojects/frontend/src/xtext/XtextClient.ts create mode 100644 subprojects/frontend/src/xtext/XtextWebSocketClient.ts create mode 100644 subprojects/frontend/src/xtext/xtextMessages.ts create mode 100644 subprojects/frontend/src/xtext/xtextServiceResults.ts (limited to 'subprojects/frontend/src/xtext') 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]; + } +} diff --git a/subprojects/frontend/src/xtext/HighlightingService.ts b/subprojects/frontend/src/xtext/HighlightingService.ts new file mode 100644 index 00000000..dfbb4a19 --- /dev/null +++ b/subprojects/frontend/src/xtext/HighlightingService.ts @@ -0,0 +1,37 @@ +import type { EditorStore } from '../editor/EditorStore'; +import type { IHighlightRange } from '../editor/semanticHighlighting'; +import type { UpdateService } from './UpdateService'; +import { highlightingResult } from './xtextServiceResults'; + +export class HighlightingService { + private readonly store: EditorStore; + + private readonly updateService: UpdateService; + + constructor(store: EditorStore, updateService: UpdateService) { + this.store = store; + this.updateService = updateService; + } + + onPush(push: unknown): void { + const { regions } = highlightingResult.parse(push); + const allChanges = this.updateService.computeChangesSinceLastUpdate(); + const ranges: IHighlightRange[] = []; + regions.forEach(({ offset, length, styleClasses }) => { + if (styleClasses.length === 0) { + return; + } + const from = allChanges.mapPos(offset); + const to = allChanges.mapPos(offset + length); + if (to <= from) { + return; + } + ranges.push({ + from, + to, + classes: styleClasses, + }); + }); + this.store.updateSemanticHighlighting(ranges); + } +} diff --git a/subprojects/frontend/src/xtext/OccurrencesService.ts b/subprojects/frontend/src/xtext/OccurrencesService.ts new file mode 100644 index 00000000..bc865537 --- /dev/null +++ b/subprojects/frontend/src/xtext/OccurrencesService.ts @@ -0,0 +1,127 @@ +import { Transaction } from '@codemirror/state'; + +import type { EditorStore } from '../editor/EditorStore'; +import type { IOccurrence } from '../editor/findOccurrences'; +import type { UpdateService } from './UpdateService'; +import { getLogger } from '../utils/logger'; +import { Timer } from '../utils/Timer'; +import { XtextWebSocketClient } from './XtextWebSocketClient'; +import { + isConflictResult, + occurrencesResult, + TextRegion, +} from './xtextServiceResults'; + +const FIND_OCCURRENCES_TIMEOUT_MS = 1000; + +// Must clear occurrences asynchronously from `onTransaction`, +// because we must not emit a conflicting transaction when handling the pending transaction. +const CLEAR_OCCURRENCES_TIMEOUT_MS = 10; + +const log = getLogger('xtext.OccurrencesService'); + +function transformOccurrences(regions: TextRegion[]): IOccurrence[] { + const occurrences: IOccurrence[] = []; + regions.forEach(({ offset, length }) => { + if (length > 0) { + occurrences.push({ + from: offset, + to: offset + length, + }); + } + }); + return occurrences; +} + +export class OccurrencesService { + private readonly store: EditorStore; + + private readonly webSocketClient: XtextWebSocketClient; + + private readonly updateService: UpdateService; + + private hasOccurrences = false; + + private readonly findOccurrencesTimer = new Timer(() => { + this.handleFindOccurrences(); + }, FIND_OCCURRENCES_TIMEOUT_MS); + + private readonly clearOccurrencesTimer = new Timer(() => { + this.clearOccurrences(); + }, CLEAR_OCCURRENCES_TIMEOUT_MS); + + constructor( + store: EditorStore, + webSocketClient: XtextWebSocketClient, + updateService: UpdateService, + ) { + this.store = store; + this.webSocketClient = webSocketClient; + this.updateService = updateService; + } + + onTransaction(transaction: Transaction): void { + if (transaction.docChanged) { + this.clearOccurrencesTimer.schedule(); + this.findOccurrencesTimer.reschedule(); + } + if (transaction.isUserEvent('select')) { + this.findOccurrencesTimer.reschedule(); + } + } + + private handleFindOccurrences() { + this.clearOccurrencesTimer.cancel(); + this.updateOccurrences().catch((error) => { + log.error('Unexpected error while updating occurrences', error); + this.clearOccurrences(); + }); + } + + private async updateOccurrences() { + await this.updateService.update(); + const result = await this.webSocketClient.send({ + resource: this.updateService.resourceName, + serviceType: 'occurrences', + expectedStateId: this.updateService.xtextStateId, + caretOffset: this.store.state.selection.main.head, + }); + const allChanges = this.updateService.computeChangesSinceLastUpdate(); + if (!allChanges.empty || isConflictResult(result, 'canceled')) { + // Stale occurrences result, the user already made some changes. + // We can safely ignore the occurrences and schedule a new find occurrences call. + this.clearOccurrences(); + this.findOccurrencesTimer.schedule(); + return; + } + const parsedOccurrencesResult = occurrencesResult.safeParse(result); + if (!parsedOccurrencesResult.success) { + log.error( + 'Unexpected occurences result', + result, + 'not an OccurrencesResult: ', + parsedOccurrencesResult.error, + ); + this.clearOccurrences(); + return; + } + const { stateId, writeRegions, readRegions } = parsedOccurrencesResult.data; + if (stateId !== this.updateService.xtextStateId) { + log.error('Unexpected state id, expected:', this.updateService.xtextStateId, 'got:', stateId); + this.clearOccurrences(); + return; + } + const write = transformOccurrences(writeRegions); + const read = transformOccurrences(readRegions); + this.hasOccurrences = write.length > 0 || read.length > 0; + log.debug('Found', write.length, 'write and', read.length, 'read occurrences'); + this.store.updateOccurrences(write, read); + } + + private clearOccurrences() { + if (this.hasOccurrences) { + this.store.updateOccurrences([], []); + this.hasOccurrences = false; + } + } +} diff --git a/subprojects/frontend/src/xtext/UpdateService.ts b/subprojects/frontend/src/xtext/UpdateService.ts new file mode 100644 index 00000000..e78944a9 --- /dev/null +++ b/subprojects/frontend/src/xtext/UpdateService.ts @@ -0,0 +1,363 @@ +import { + ChangeDesc, + ChangeSet, + ChangeSpec, + StateEffect, + Transaction, +} from '@codemirror/state'; +import { nanoid } from 'nanoid'; + +import type { EditorStore } from '../editor/EditorStore'; +import type { XtextWebSocketClient } from './XtextWebSocketClient'; +import { ConditionVariable } from '../utils/ConditionVariable'; +import { getLogger } from '../utils/logger'; +import { Timer } from '../utils/Timer'; +import { + ContentAssistEntry, + contentAssistResult, + documentStateResult, + formattingResult, + isConflictResult, +} from './xtextServiceResults'; + +const UPDATE_TIMEOUT_MS = 500; + +const WAIT_FOR_UPDATE_TIMEOUT_MS = 1000; + +const log = getLogger('xtext.UpdateService'); + +const setDirtyChanges = StateEffect.define(); + +export interface IAbortSignal { + aborted: boolean; +} + +export class UpdateService { + resourceName: string; + + xtextStateId: string | null = null; + + private readonly store: EditorStore; + + /** + * The changes being synchronized to the server if a full or delta text update is running, + * `null` otherwise. + */ + private pendingUpdate: ChangeSet | null = null; + + /** + * Local changes not yet sychronized to the server and not part of the running update, if any. + */ + private dirtyChanges: ChangeSet; + + private readonly webSocketClient: XtextWebSocketClient; + + private readonly updatedCondition = new ConditionVariable( + () => this.pendingUpdate === null && this.xtextStateId !== null, + WAIT_FOR_UPDATE_TIMEOUT_MS, + ); + + private readonly idleUpdateTimer = new Timer(() => { + this.handleIdleUpdate(); + }, UPDATE_TIMEOUT_MS); + + constructor(store: EditorStore, webSocketClient: XtextWebSocketClient) { + this.resourceName = `${nanoid(7)}.problem`; + this.store = store; + this.dirtyChanges = this.newEmptyChangeSet(); + this.webSocketClient = webSocketClient; + } + + onReconnect(): void { + this.xtextStateId = null; + this.updateFullText().catch((error) => { + log.error('Unexpected error during initial update', error); + }); + } + + onTransaction(transaction: Transaction): void { + const setDirtyChangesEffect = transaction.effects.find( + (effect) => effect.is(setDirtyChanges), + ) as StateEffect | undefined; + if (setDirtyChangesEffect) { + const { value } = setDirtyChangesEffect; + if (this.pendingUpdate !== null) { + this.pendingUpdate = ChangeSet.empty(value.length); + } + this.dirtyChanges = value; + return; + } + if (transaction.docChanged) { + this.dirtyChanges = this.dirtyChanges.compose(transaction.changes); + this.idleUpdateTimer.reschedule(); + } + } + + /** + * Computes the summary of any changes happened since the last complete update. + * + * The result reflects any changes that happened since the `xtextStateId` + * version was uploaded to the server. + * + * @return the summary of changes since the last update + */ + computeChangesSinceLastUpdate(): ChangeDesc { + return this.pendingUpdate?.composeDesc(this.dirtyChanges.desc) || this.dirtyChanges.desc; + } + + private handleIdleUpdate() { + if (!this.webSocketClient.isOpen || this.dirtyChanges.empty) { + return; + } + if (this.pendingUpdate === null) { + this.update().catch((error) => { + log.error('Unexpected error during scheduled update', error); + }); + } + this.idleUpdateTimer.reschedule(); + } + + private newEmptyChangeSet() { + return ChangeSet.of([], this.store.state.doc.length); + } + + async updateFullText(): Promise { + 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), + }); + const { stateId } = documentStateResult.parse(result); + return [stateId, undefined]; + } + + /** + * Makes sure that the document state on the server reflects recent + * local changes. + * + * Performs either an update with delta text or a full text update if needed. + * If there are not local dirty changes, the promise resolves immediately. + * + * @return a promise resolving when the update is completed + */ + 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', + requiredStateId: this.xtextStateId, + ...delta, + }); + const parsedDocumentStateResult = documentStateResult.safeParse(result); + if (parsedDocumentStateResult.success) { + return [parsedDocumentStateResult.data.stateId, undefined]; + } + if (isConflictResult(result, 'invalidStateId')) { + return this.doFallbackToUpdateFullText(); + } + throw parsedDocumentStateResult.error; + }); + } + + 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.compose(this.dirtyChanges); + this.dirtyChanges = this.newEmptyChangeSet(); + return this.doUpdateFullText(); + } + + async fetchContentAssist( + params: Record, + signal: IAbortSignal, + ): Promise { + await this.prepareForDeltaUpdate(); + if (signal.aborted) { + return []; + } + const delta = this.computeDelta(); + if (delta !== null) { + log.trace('Editor delta', delta); + const entries = await this.withUpdate(async () => { + const result = await this.webSocketClient.send({ + ...params, + requiredStateId: this.xtextStateId, + ...delta, + }); + const parsedContentAssistResult = contentAssistResult.safeParse(result); + if (parsedContentAssistResult.success) { + const { stateId, entries: resultEntries } = parsedContentAssistResult.data; + return [stateId, resultEntries]; + } + if (isConflictResult(result, 'invalidStateId')) { + log.warn('Server state invalid during content assist'); + const [newStateId] = await this.doFallbackToUpdateFullText(); + // We must finish this state update transaction to prepare for any push events + // before querying for content assist, so we just return `null` and will query + // the content assist service later. + return [newStateId, null]; + } + throw parsedContentAssistResult.error; + }); + if (entries !== null) { + return entries; + } + if (signal.aborted) { + return []; + } + } + // Poscondition of `prepareForDeltaUpdate`: `xtextStateId !== null` + return this.doFetchContentAssist(params, this.xtextStateId as string); + } + + private async doFetchContentAssist(params: Record, expectedStateId: string) { + const result = await this.webSocketClient.send({ + ...params, + requiredStateId: expectedStateId, + }); + const { stateId, entries } = contentAssistResult.parse(result); + if (stateId !== expectedStateId) { + throw new Error(`Unexpected state id, expected: ${expectedStateId} got: ${stateId}`); + } + return entries; + } + + async formatText(): Promise { + await this.update(); + let { from, to } = this.store.state.selection.main; + if (to <= from) { + from = 0; + to = this.store.state.doc.length; + } + log.debug('Formatting from', from, 'to', to); + await this.withUpdate(async () => { + const result = await this.webSocketClient.send({ + resource: this.resourceName, + serviceType: 'format', + selectionStart: from, + selectionEnd: to, + }); + const { stateId, formattedText } = formattingResult.parse(result); + this.applyBeforeDirtyChanges({ + from, + to, + insert: formattedText, + }); + return [stateId, null]; + }); + } + + private computeDelta() { + if (this.dirtyChanges.empty) { + return null; + } + let minFromA = Number.MAX_SAFE_INTEGER; + let maxToA = 0; + let minFromB = Number.MAX_SAFE_INTEGER; + let maxToB = 0; + this.dirtyChanges.iterChangedRanges((fromA, toA, fromB, toB) => { + minFromA = Math.min(minFromA, fromA); + maxToA = Math.max(maxToA, toA); + minFromB = Math.min(minFromB, fromB); + maxToB = Math.max(maxToB, toB); + }); + return { + deltaOffset: minFromA, + deltaReplaceLength: maxToA - minFromA, + deltaText: this.store.state.doc.sliceString(minFromB, maxToB), + }; + } + + private applyBeforeDirtyChanges(changeSpec: ChangeSpec) { + const pendingChanges = this.pendingUpdate?.compose(this.dirtyChanges) || this.dirtyChanges; + const revertChanges = pendingChanges.invert(this.store.state.doc); + const applyBefore = ChangeSet.of(changeSpec, revertChanges.newLength); + const redoChanges = pendingChanges.map(applyBefore.desc); + const changeSet = revertChanges.compose(applyBefore).compose(redoChanges); + this.store.dispatch({ + changes: changeSet, + effects: [ + setDirtyChanges.of(redoChanges), + ], + }); + } + + /** + * Executes an asynchronous callback that updates the state on the server. + * + * Ensures that updates happen sequentially and manages `pendingUpdate` + * and `dirtyChanges` to reflect changes being synchronized to the server + * and not yet synchronized to the server, respectively. + * + * Optionally, `callback` may return a second value that is retured by this function. + * + * Once the remote procedure call to update the server state finishes + * and returns the new `stateId`, `callback` must return _immediately_ + * to ensure that the local `stateId` is updated likewise to be able to handle + * push messages referring to the new `stateId` from the server. + * If additional work is needed to compute the second value in some cases, + * use `T | null` instead of `T` as a return type and signal the need for additional + * computations by returning `null`. Thus additional computations can be performed + * outside of the critical section. + * + * @param callback the asynchronous callback that updates the server state + * @return a promise resolving to the second value returned by `callback` + */ + private async withUpdate(callback: () => Promise<[string, T]>): Promise { + if (this.pendingUpdate !== null) { + throw new Error('Another update is pending, will not perform update'); + } + this.pendingUpdate = this.dirtyChanges; + this.dirtyChanges = this.newEmptyChangeSet(); + let newStateId: string | null = null; + try { + let result: T; + [newStateId, result] = await callback(); + this.xtextStateId = newStateId; + this.pendingUpdate = null; + this.updatedCondition.notifyAll(); + 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.compose(this.dirtyChanges); + } + this.pendingUpdate = null; + this.webSocketClient.forceReconnectOnError(); + this.updatedCondition.rejectAll(e); + throw e; + } + } + + /** + * Ensures that there is some state available on the server (`xtextStateId`) + * and that there is not pending update. + * + * After this function resolves, a delta text update is possible. + * + * @return a promise resolving when there is a valid state id but no pending update + */ + private async prepareForDeltaUpdate() { + // If no update is pending, but the full text hasn't been uploaded to the server yet, + // we must start a full text upload. + if (this.pendingUpdate === null && this.xtextStateId === null) { + await this.updateFullText(); + } + await this.updatedCondition.waitFor(); + } +} diff --git a/subprojects/frontend/src/xtext/ValidationService.ts b/subprojects/frontend/src/xtext/ValidationService.ts new file mode 100644 index 00000000..ff7d3700 --- /dev/null +++ b/subprojects/frontend/src/xtext/ValidationService.ts @@ -0,0 +1,39 @@ +import type { Diagnostic } from '@codemirror/lint'; + +import type { EditorStore } from '../editor/EditorStore'; +import type { UpdateService } from './UpdateService'; +import { validationResult } from './xtextServiceResults'; + +export class ValidationService { + private readonly store: EditorStore; + + private readonly updateService: UpdateService; + + constructor(store: EditorStore, updateService: UpdateService) { + this.store = store; + this.updateService = updateService; + } + + onPush(push: unknown): void { + const { issues } = validationResult.parse(push); + const allChanges = this.updateService.computeChangesSinceLastUpdate(); + const diagnostics: Diagnostic[] = []; + issues.forEach(({ + offset, + length, + severity, + description, + }) => { + if (severity === 'ignore') { + return; + } + diagnostics.push({ + from: allChanges.mapPos(offset), + to: allChanges.mapPos(offset + length), + severity, + message: description, + }); + }); + this.store.updateDiagnostics(diagnostics); + } +} diff --git a/subprojects/frontend/src/xtext/XtextClient.ts b/subprojects/frontend/src/xtext/XtextClient.ts new file mode 100644 index 00000000..0898e725 --- /dev/null +++ b/subprojects/frontend/src/xtext/XtextClient.ts @@ -0,0 +1,86 @@ +import type { + CompletionContext, + CompletionResult, +} from '@codemirror/autocomplete'; +import type { Transaction } from '@codemirror/state'; + +import type { EditorStore } from '../editor/EditorStore'; +import { ContentAssistService } from './ContentAssistService'; +import { HighlightingService } from './HighlightingService'; +import { OccurrencesService } from './OccurrencesService'; +import { UpdateService } from './UpdateService'; +import { getLogger } from '../utils/logger'; +import { ValidationService } from './ValidationService'; +import { XtextWebSocketClient } from './XtextWebSocketClient'; +import { XtextWebPushService } from './xtextMessages'; + +const log = getLogger('xtext.XtextClient'); + +export class XtextClient { + private readonly webSocketClient: XtextWebSocketClient; + + private readonly updateService: UpdateService; + + private readonly contentAssistService: ContentAssistService; + + private readonly highlightingService: HighlightingService; + + private readonly validationService: ValidationService; + + private readonly occurrencesService: OccurrencesService; + + constructor(store: EditorStore) { + this.webSocketClient = new XtextWebSocketClient( + () => this.updateService.onReconnect(), + (resource, stateId, service, push) => this.onPush(resource, stateId, service, push), + ); + this.updateService = new UpdateService(store, this.webSocketClient); + this.contentAssistService = new ContentAssistService(this.updateService); + this.highlightingService = new HighlightingService(store, this.updateService); + this.validationService = new ValidationService(store, this.updateService); + this.occurrencesService = new OccurrencesService( + store, + this.webSocketClient, + this.updateService, + ); + } + + onTransaction(transaction: Transaction): void { + // `ContentAssistService.prototype.onTransaction` needs the dirty change desc + // _before_ the current edit, so we call it before `updateService`. + this.contentAssistService.onTransaction(transaction); + this.updateService.onTransaction(transaction); + this.occurrencesService.onTransaction(transaction); + } + + private onPush(resource: string, stateId: string, service: XtextWebPushService, push: unknown) { + const { resourceName, xtextStateId } = this.updateService; + if (resource !== resourceName) { + log.error('Unknown resource name: expected:', resourceName, 'got:', resource); + return; + } + if (stateId !== xtextStateId) { + log.error('Unexpected xtext state id: expected:', xtextStateId, 'got:', stateId); + // The current push message might be stale (referring to a previous state), + // so this is not neccessarily an error and there is no need to force-reconnect. + return; + } + switch (service) { + case 'highlight': + this.highlightingService.onPush(push); + return; + case 'validate': + this.validationService.onPush(push); + } + } + + contentAssist(context: CompletionContext): Promise { + return this.contentAssistService.contentAssist(context); + } + + formatText(): void { + this.updateService.formatText().catch((e) => { + log.error('Error while formatting text', e); + }); + } +} diff --git a/subprojects/frontend/src/xtext/XtextWebSocketClient.ts b/subprojects/frontend/src/xtext/XtextWebSocketClient.ts new file mode 100644 index 00000000..2ce20a54 --- /dev/null +++ b/subprojects/frontend/src/xtext/XtextWebSocketClient.ts @@ -0,0 +1,362 @@ +import { nanoid } from 'nanoid'; + +import { getLogger } from '../utils/logger'; +import { PendingTask } from '../utils/PendingTask'; +import { Timer } from '../utils/Timer'; +import { + xtextWebErrorResponse, + XtextWebRequest, + xtextWebOkResponse, + xtextWebPushMessage, + XtextWebPushService, +} from './xtextMessages'; +import { pongResult } from './xtextServiceResults'; + +const XTEXT_SUBPROTOCOL_V1 = 'tools.refinery.language.web.xtext.v1'; + +const WEBSOCKET_CLOSE_OK = 1000; + +const RECONNECT_DELAY_MS = [200, 1000, 5000, 30_000]; + +const MAX_RECONNECT_DELAY_MS = RECONNECT_DELAY_MS[RECONNECT_DELAY_MS.length - 1]; + +const BACKGROUND_IDLE_TIMEOUT_MS = 5 * 60 * 1000; + +const PING_TIMEOUT_MS = 10 * 1000; + +const REQUEST_TIMEOUT_MS = 1000; + +const log = getLogger('xtext.XtextWebSocketClient'); + +export type ReconnectHandler = () => void; + +export type PushHandler = ( + resourceId: string, + stateId: string, + service: XtextWebPushService, + data: unknown, +) => void; + +enum State { + Initial, + Opening, + TabVisible, + TabHiddenIdle, + TabHiddenWaiting, + Error, + TimedOut, +} + +export class XtextWebSocketClient { + private nextMessageId = 0; + + private connection!: WebSocket; + + private readonly pendingRequests = new Map>(); + + private readonly onReconnect: ReconnectHandler; + + private readonly onPush: PushHandler; + + private state = State.Initial; + + private reconnectTryCount = 0; + + private readonly idleTimer = new Timer(() => { + this.handleIdleTimeout(); + }, BACKGROUND_IDLE_TIMEOUT_MS); + + private readonly pingTimer = new Timer(() => { + this.sendPing(); + }, PING_TIMEOUT_MS); + + private readonly reconnectTimer = new Timer(() => { + this.handleReconnect(); + }); + + constructor(onReconnect: ReconnectHandler, onPush: PushHandler) { + this.onReconnect = onReconnect; + this.onPush = onPush; + document.addEventListener('visibilitychange', () => { + this.handleVisibilityChange(); + }); + this.reconnect(); + } + + private get isLogicallyClosed(): boolean { + return this.state === State.Error || this.state === State.TimedOut; + } + + get isOpen(): boolean { + return this.state === State.TabVisible + || this.state === State.TabHiddenIdle + || this.state === State.TabHiddenWaiting; + } + + private reconnect() { + if (this.isOpen || this.state === State.Opening) { + log.error('Trying to reconnect from', this.state); + return; + } + this.state = State.Opening; + const webSocketServer = window.origin.replace(/^http/, 'ws'); + const webSocketUrl = `${webSocketServer}/xtext-service`; + this.connection = new WebSocket(webSocketUrl, XTEXT_SUBPROTOCOL_V1); + this.connection.addEventListener('open', () => { + if (this.connection.protocol !== XTEXT_SUBPROTOCOL_V1) { + log.error('Unknown subprotocol', this.connection.protocol, 'selected by server'); + this.forceReconnectOnError(); + } + if (document.visibilityState === 'hidden') { + this.handleTabHidden(); + } else { + this.handleTabVisibleConnected(); + } + log.info('Connected to websocket'); + this.nextMessageId = 0; + this.reconnectTryCount = 0; + this.pingTimer.schedule(); + this.onReconnect(); + }); + this.connection.addEventListener('error', (event) => { + log.error('Unexpected websocket error', event); + this.forceReconnectOnError(); + }); + this.connection.addEventListener('message', (event) => { + this.handleMessage(event.data); + }); + this.connection.addEventListener('close', (event) => { + if (this.isLogicallyClosed && event.code === WEBSOCKET_CLOSE_OK + && this.pendingRequests.size === 0) { + log.info('Websocket closed'); + return; + } + log.error('Websocket closed unexpectedly', event.code, event.reason); + this.forceReconnectOnError(); + }); + } + + private handleVisibilityChange() { + if (document.visibilityState === 'hidden') { + if (this.state === State.TabVisible) { + this.handleTabHidden(); + } + return; + } + this.idleTimer.cancel(); + if (this.state === State.TabHiddenIdle || this.state === State.TabHiddenWaiting) { + this.handleTabVisibleConnected(); + return; + } + if (this.state === State.TimedOut) { + this.reconnect(); + } + } + + private handleTabHidden() { + log.debug('Tab hidden while websocket is connected'); + this.state = State.TabHiddenIdle; + this.idleTimer.schedule(); + } + + private handleTabVisibleConnected() { + log.debug('Tab visible while websocket is connected'); + this.state = State.TabVisible; + } + + private handleIdleTimeout() { + log.trace('Waiting for pending tasks before disconnect'); + if (this.state === State.TabHiddenIdle) { + this.state = State.TabHiddenWaiting; + this.handleWaitingForDisconnect(); + } + } + + private handleWaitingForDisconnect() { + if (this.state !== State.TabHiddenWaiting) { + return; + } + const pending = this.pendingRequests.size; + if (pending === 0) { + log.info('Closing idle websocket'); + this.state = State.TimedOut; + this.closeConnection(1000, 'idle timeout'); + return; + } + log.info('Waiting for', pending, 'pending requests before closing websocket'); + } + + private sendPing() { + if (!this.isOpen) { + return; + } + const ping = nanoid(); + log.trace('Ping', ping); + this.send({ ping }).then((result) => { + const parsedPongResult = pongResult.safeParse(result); + if (parsedPongResult.success && parsedPongResult.data.pong === ping) { + log.trace('Pong', ping); + this.pingTimer.schedule(); + } else { + log.error('Invalid pong:', parsedPongResult, 'expected:', ping); + this.forceReconnectOnError(); + } + }).catch((error) => { + log.error('Error while waiting for ping', error); + this.forceReconnectOnError(); + }); + } + + send(request: unknown): Promise { + if (!this.isOpen) { + throw new Error('Not open'); + } + const messageId = this.nextMessageId.toString(16); + if (messageId in this.pendingRequests) { + log.error('Message id wraparound still pending', messageId); + this.rejectRequest(messageId, new Error('Message id wraparound')); + } + if (this.nextMessageId >= Number.MAX_SAFE_INTEGER) { + this.nextMessageId = 0; + } else { + this.nextMessageId += 1; + } + const message = JSON.stringify({ + id: messageId, + request, + } as XtextWebRequest); + log.trace('Sending message', message); + return new Promise((resolve, reject) => { + const task = new PendingTask(resolve, reject, REQUEST_TIMEOUT_MS, () => { + this.removePendingRequest(messageId); + }); + this.pendingRequests.set(messageId, task); + this.connection.send(message); + }); + } + + private handleMessage(messageStr: unknown) { + if (typeof messageStr !== 'string') { + log.error('Unexpected binary message', messageStr); + this.forceReconnectOnError(); + return; + } + log.trace('Incoming websocket message', messageStr); + let message: unknown; + try { + message = JSON.parse(messageStr); + } catch (error) { + log.error('Json parse error', error); + this.forceReconnectOnError(); + return; + } + const okResponse = xtextWebOkResponse.safeParse(message); + if (okResponse.success) { + const { id, response } = okResponse.data; + this.resolveRequest(id, response); + return; + } + const errorResponse = xtextWebErrorResponse.safeParse(message); + if (errorResponse.success) { + const { id, error, message: errorMessage } = errorResponse.data; + this.rejectRequest(id, new Error(`${error} error: ${errorMessage}`)); + if (error === 'server') { + log.error('Reconnecting due to server error: ', errorMessage); + this.forceReconnectOnError(); + } + return; + } + const pushMessage = xtextWebPushMessage.safeParse(message); + if (pushMessage.success) { + const { + resource, + stateId, + service, + push, + } = pushMessage.data; + this.onPush(resource, stateId, service, push); + } else { + log.error( + 'Unexpected websocket message:', + message, + 'not ok response because:', + okResponse.error, + 'not error response because:', + errorResponse.error, + 'not push message because:', + pushMessage.error, + ); + this.forceReconnectOnError(); + } + } + + private resolveRequest(messageId: string, value: unknown) { + const pendingRequest = this.pendingRequests.get(messageId); + if (pendingRequest) { + pendingRequest.resolve(value); + this.removePendingRequest(messageId); + return; + } + log.error('Trying to resolve unknown request', messageId, 'with', value); + } + + private rejectRequest(messageId: string, reason?: unknown) { + const pendingRequest = this.pendingRequests.get(messageId); + if (pendingRequest) { + pendingRequest.reject(reason); + this.removePendingRequest(messageId); + return; + } + log.error('Trying to reject unknown request', messageId, 'with', reason); + } + + private removePendingRequest(messageId: string) { + this.pendingRequests.delete(messageId); + this.handleWaitingForDisconnect(); + } + + forceReconnectOnError(): void { + if (this.isLogicallyClosed) { + return; + } + this.abortPendingRequests(); + this.closeConnection(1000, 'reconnecting due to error'); + log.error('Reconnecting after delay due to error'); + this.handleErrorState(); + } + + private abortPendingRequests() { + this.pendingRequests.forEach((request) => { + request.reject(new Error('Websocket disconnect')); + }); + this.pendingRequests.clear(); + } + + private closeConnection(code: number, reason: string) { + this.pingTimer.cancel(); + const { readyState } = this.connection; + if (readyState !== WebSocket.CLOSING && readyState !== WebSocket.CLOSED) { + this.connection.close(code, reason); + } + } + + private handleErrorState() { + this.state = State.Error; + this.reconnectTryCount += 1; + const delay = RECONNECT_DELAY_MS[this.reconnectTryCount - 1] || MAX_RECONNECT_DELAY_MS; + log.info('Reconnecting in', delay, 'ms'); + this.reconnectTimer.schedule(delay); + } + + private handleReconnect() { + if (this.state !== State.Error) { + log.error('Unexpected reconnect in', this.state); + return; + } + if (document.visibilityState === 'hidden') { + this.state = State.TimedOut; + } else { + this.reconnect(); + } + } +} diff --git a/subprojects/frontend/src/xtext/xtextMessages.ts b/subprojects/frontend/src/xtext/xtextMessages.ts new file mode 100644 index 00000000..c4305fcf --- /dev/null +++ b/subprojects/frontend/src/xtext/xtextMessages.ts @@ -0,0 +1,40 @@ +import { z } from 'zod'; + +export const xtextWebRequest = z.object({ + id: z.string().nonempty(), + request: z.unknown(), +}); + +export type XtextWebRequest = z.infer; + +export const xtextWebOkResponse = z.object({ + id: z.string().nonempty(), + response: z.unknown(), +}); + +export type XtextWebOkResponse = z.infer; + +export const xtextWebErrorKind = z.enum(['request', 'server']); + +export type XtextWebErrorKind = z.infer; + +export const xtextWebErrorResponse = z.object({ + id: z.string().nonempty(), + error: xtextWebErrorKind, + message: z.string(), +}); + +export type XtextWebErrorResponse = z.infer; + +export const xtextWebPushService = z.enum(['highlight', 'validate']); + +export type XtextWebPushService = z.infer; + +export const xtextWebPushMessage = z.object({ + resource: z.string().nonempty(), + stateId: z.string().nonempty(), + service: xtextWebPushService, + push: z.unknown(), +}); + +export type XtextWebPushMessage = z.infer; diff --git a/subprojects/frontend/src/xtext/xtextServiceResults.ts b/subprojects/frontend/src/xtext/xtextServiceResults.ts new file mode 100644 index 00000000..f79b059c --- /dev/null +++ b/subprojects/frontend/src/xtext/xtextServiceResults.ts @@ -0,0 +1,112 @@ +import { z } from 'zod'; + +export const pongResult = z.object({ + pong: z.string().nonempty(), +}); + +export type PongResult = z.infer; + +export const documentStateResult = z.object({ + stateId: z.string().nonempty(), +}); + +export type DocumentStateResult = z.infer; + +export const conflict = z.enum(['invalidStateId', 'canceled']); + +export type Conflict = z.infer; + +export const serviceConflictResult = z.object({ + conflict, +}); + +export type ServiceConflictResult = z.infer; + +export function isConflictResult(result: unknown, conflictType: Conflict): boolean { + const parsedConflictResult = serviceConflictResult.safeParse(result); + return parsedConflictResult.success && parsedConflictResult.data.conflict === conflictType; +} + +export const severity = z.enum(['error', 'warning', 'info', 'ignore']); + +export type Severity = z.infer; + +export const issue = z.object({ + description: z.string().nonempty(), + severity, + line: z.number().int(), + column: z.number().int().nonnegative(), + offset: z.number().int().nonnegative(), + length: z.number().int().nonnegative(), +}); + +export type Issue = z.infer; + +export const validationResult = z.object({ + issues: issue.array(), +}); + +export type ValidationResult = z.infer; + +export const replaceRegion = z.object({ + offset: z.number().int().nonnegative(), + length: z.number().int().nonnegative(), + text: z.string(), +}); + +export type ReplaceRegion = z.infer; + +export const textRegion = z.object({ + offset: z.number().int().nonnegative(), + length: z.number().int().nonnegative(), +}); + +export type TextRegion = z.infer; + +export const contentAssistEntry = z.object({ + prefix: z.string(), + proposal: z.string().nonempty(), + label: z.string().optional(), + description: z.string().nonempty().optional(), + documentation: z.string().nonempty().optional(), + escapePosition: z.number().int().nonnegative().optional(), + textReplacements: replaceRegion.array(), + editPositions: textRegion.array(), + kind: z.string().nonempty(), +}); + +export type ContentAssistEntry = z.infer; + +export const contentAssistResult = documentStateResult.extend({ + entries: contentAssistEntry.array(), +}); + +export type ContentAssistResult = z.infer; + +export const highlightingRegion = z.object({ + offset: z.number().int().nonnegative(), + length: z.number().int().nonnegative(), + styleClasses: z.string().nonempty().array(), +}); + +export type HighlightingRegion = z.infer; + +export const highlightingResult = z.object({ + regions: highlightingRegion.array(), +}); + +export type HighlightingResult = z.infer; + +export const occurrencesResult = documentStateResult.extend({ + writeRegions: textRegion.array(), + readRegions: textRegion.array(), +}); + +export type OccurrencesResult = z.infer; + +export const formattingResult = documentStateResult.extend({ + formattedText: z.string(), + replaceRegion: textRegion, +}); + +export type FormattingResult = z.infer; -- cgit v1.2.3-54-g00ecf