From cdb493b0a47bcf64e8e670b94fa399fcd731f531 Mon Sep 17 00:00:00 2001 From: Kristóf Marussy Date: Sat, 30 Oct 2021 13:48:52 +0200 Subject: chore(web): refactor xtext client --- language-web/src/main/js/editor/EditorStore.ts | 4 +- language-web/src/main/js/editor/XtextClient.ts | 424 ------------------ .../src/main/js/editor/XtextWebSocketClient.ts | 345 --------------- language-web/src/main/js/editor/folding.ts | 97 ----- language-web/src/main/js/editor/indentation.ts | 84 ---- language-web/src/main/js/editor/problem.grammar | 129 ------ .../src/main/js/editor/problemLanguageSupport.ts | 83 ---- language-web/src/main/js/editor/xtextMessages.ts | 62 --- .../src/main/js/editor/xtextServiceResults.ts | 200 --------- language-web/src/main/js/language/folding.ts | 97 +++++ language-web/src/main/js/language/indentation.ts | 84 ++++ language-web/src/main/js/language/problem.grammar | 129 ++++++ .../src/main/js/language/problemLanguageSupport.ts | 83 ++++ .../src/main/js/xtext/CodeMirrorEditorContext.js | 111 ----- .../src/main/js/xtext/ContentAssistService.ts | 133 ++++++ language-web/src/main/js/xtext/ServiceBuilder.js | 285 ------------- language-web/src/main/js/xtext/UpdateService.ts | 271 ++++++++++++ .../src/main/js/xtext/ValidationService.ts | 40 ++ language-web/src/main/js/xtext/XtextClient.ts | 73 ++++ .../src/main/js/xtext/XtextWebSocketClient.ts | 345 +++++++++++++++ language-web/src/main/js/xtext/compatibility.js | 63 --- .../main/js/xtext/services/ContentAssistService.js | 132 ------ .../main/js/xtext/services/FormattingService.js | 52 --- .../main/js/xtext/services/HighlightingService.js | 33 -- .../src/main/js/xtext/services/HoverService.js | 59 --- .../main/js/xtext/services/LoadResourceService.js | 42 -- .../main/js/xtext/services/OccurrencesService.js | 39 -- .../main/js/xtext/services/SaveResourceService.js | 32 -- .../src/main/js/xtext/services/UpdateService.js | 159 ------- .../main/js/xtext/services/ValidationService.js | 33 -- .../src/main/js/xtext/services/XtextService.js | 280 ------------ .../src/main/js/xtext/xtext-codemirror.d.ts | 43 -- language-web/src/main/js/xtext/xtext-codemirror.js | 473 --------------------- language-web/src/main/js/xtext/xtextMessages.ts | 62 +++ .../src/main/js/xtext/xtextServiceResults.ts | 200 +++++++++ 35 files changed, 1519 insertions(+), 3262 deletions(-) delete mode 100644 language-web/src/main/js/editor/XtextClient.ts delete mode 100644 language-web/src/main/js/editor/XtextWebSocketClient.ts delete mode 100644 language-web/src/main/js/editor/folding.ts delete mode 100644 language-web/src/main/js/editor/indentation.ts delete mode 100644 language-web/src/main/js/editor/problem.grammar delete mode 100644 language-web/src/main/js/editor/problemLanguageSupport.ts delete mode 100644 language-web/src/main/js/editor/xtextMessages.ts delete mode 100644 language-web/src/main/js/editor/xtextServiceResults.ts create mode 100644 language-web/src/main/js/language/folding.ts create mode 100644 language-web/src/main/js/language/indentation.ts create mode 100644 language-web/src/main/js/language/problem.grammar create mode 100644 language-web/src/main/js/language/problemLanguageSupport.ts delete mode 100644 language-web/src/main/js/xtext/CodeMirrorEditorContext.js create mode 100644 language-web/src/main/js/xtext/ContentAssistService.ts delete mode 100644 language-web/src/main/js/xtext/ServiceBuilder.js create mode 100644 language-web/src/main/js/xtext/UpdateService.ts create mode 100644 language-web/src/main/js/xtext/ValidationService.ts create mode 100644 language-web/src/main/js/xtext/XtextClient.ts create mode 100644 language-web/src/main/js/xtext/XtextWebSocketClient.ts delete mode 100644 language-web/src/main/js/xtext/compatibility.js delete mode 100644 language-web/src/main/js/xtext/services/ContentAssistService.js delete mode 100644 language-web/src/main/js/xtext/services/FormattingService.js delete mode 100644 language-web/src/main/js/xtext/services/HighlightingService.js delete mode 100644 language-web/src/main/js/xtext/services/HoverService.js delete mode 100644 language-web/src/main/js/xtext/services/LoadResourceService.js delete mode 100644 language-web/src/main/js/xtext/services/OccurrencesService.js delete mode 100644 language-web/src/main/js/xtext/services/SaveResourceService.js delete mode 100644 language-web/src/main/js/xtext/services/UpdateService.js delete mode 100644 language-web/src/main/js/xtext/services/ValidationService.js delete mode 100644 language-web/src/main/js/xtext/services/XtextService.js delete mode 100644 language-web/src/main/js/xtext/xtext-codemirror.d.ts delete mode 100644 language-web/src/main/js/xtext/xtext-codemirror.js create mode 100644 language-web/src/main/js/xtext/xtextMessages.ts create mode 100644 language-web/src/main/js/xtext/xtextServiceResults.ts (limited to 'language-web/src/main/js') diff --git a/language-web/src/main/js/editor/EditorStore.ts b/language-web/src/main/js/editor/EditorStore.ts index dcc69fd1..be9295bf 100644 --- a/language-web/src/main/js/editor/EditorStore.ts +++ b/language-web/src/main/js/editor/EditorStore.ts @@ -42,10 +42,10 @@ import { reaction, } from 'mobx'; +import { problemLanguageSupport } from '../language/problemLanguageSupport'; import { getLogger } from '../logging'; -import { problemLanguageSupport } from './problemLanguageSupport'; import type { ThemeStore } from '../theme/ThemeStore'; -import { XtextClient } from './XtextClient'; +import { XtextClient } from '../xtext/XtextClient'; const log = getLogger('EditorStore'); diff --git a/language-web/src/main/js/editor/XtextClient.ts b/language-web/src/main/js/editor/XtextClient.ts deleted file mode 100644 index 6f789fb7..00000000 --- a/language-web/src/main/js/editor/XtextClient.ts +++ /dev/null @@ -1,424 +0,0 @@ -import { - Completion, - CompletionContext, - CompletionResult, -} from '@codemirror/autocomplete'; -import type { Diagnostic } from '@codemirror/lint'; -import { - ChangeDesc, - ChangeSet, - Transaction, -} from '@codemirror/state'; -import { nanoid } from 'nanoid'; - -import type { EditorStore } from './EditorStore'; -import { getLogger } from '../logging'; -import { Timer } from '../utils/Timer'; -import { - IContentAssistEntry, - isContentAssistResult, - isDocumentStateResult, - isInvalidStateIdConflictResult, - isValidationResult, -} from './xtextServiceResults'; -import { XtextWebSocketClient } from './XtextWebSocketClient'; -import { PendingTask } from '../utils/PendingTask'; - -const UPDATE_TIMEOUT_MS = 500; - -const WAIT_FOR_UPDATE_TIMEOUT_MS = 1000; - -const log = getLogger('XtextClient'); - -export class XtextClient { - resourceName: string; - - webSocketClient: XtextWebSocketClient; - - xtextStateId: string | null = null; - - pendingUpdate: ChangeDesc | null; - - dirtyChanges: ChangeDesc; - - lastCompletion: CompletionResult | null = null; - - updateListeners: PendingTask[] = []; - - updateTimer = new Timer(() => { - this.handleUpdate(); - }, UPDATE_TIMEOUT_MS); - - store: EditorStore; - - constructor(store: EditorStore) { - this.resourceName = `${nanoid(7)}.problem`; - this.pendingUpdate = null; - this.store = store; - this.dirtyChanges = this.newEmptyChangeDesc(); - this.webSocketClient = new XtextWebSocketClient( - async () => { - this.xtextStateId = null; - await this.updateFullText(); - }, - async (resource, stateId, service, push) => { - await this.onPush(resource, stateId, service, push); - }, - ); - } - - 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(); - } - } - - private async onPush(resource: string, stateId: string, service: string, push: unknown) { - if (resource !== this.resourceName) { - log.error('Unknown resource name: expected:', this.resourceName, 'got:', resource); - return; - } - if (stateId !== this.xtextStateId) { - log.error('Unexpected xtext state id: expected:', this.xtextStateId, 'got:', resource); - await this.updateFullText(); - } - switch (service) { - case 'validate': - this.onValidate(push); - return; - case 'highlight': - // TODO - return; - default: - log.error('Unknown push service:', service); - break; - } - } - - private onValidate(push: unknown) { - if (!isValidationResult(push)) { - log.error('Invalid validation result', push); - return; - } - const allChanges = this.computeChangesSinceLastUpdate(); - const diagnostics: Diagnostic[] = []; - push.issues.forEach((issue) => { - if (issue.severity === 'ignore') { - return; - } - diagnostics.push({ - from: allChanges.mapPos(issue.offset), - to: allChanges.mapPos(issue.offset + issue.length), - severity: issue.severity, - message: issue.description, - }); - }); - this.store.updateDiagnostics(diagnostics); - } - - private computeChangesSinceLastUpdate() { - return this.pendingUpdate?.composeDesc(this.dirtyChanges) || this.dirtyChanges; - } - - private handleUpdate() { - 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.updateTimer.reschedule(); - } - - private newEmptyChangeDesc() { - const changeSet = ChangeSet.of([], this.store.state.doc.length); - return changeSet.desc; - } - - 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', - requiredStateId: this.xtextStateId, - ...delta, - }); - if (isDocumentStateResult(result)) { - return [result.stateId, undefined]; - } - if (isInvalidStateIdConflictResult(result)) { - return this.doFallbackToUpdateFullText(); - } - log.error('Unexpected delta text update result:', result); - throw new Error('Delta text update failed'); - }); - } - - 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); - return await this.withUpdate(async () => { - const result = await this.webSocketClient.send({ - requiredStateId: this.xtextStateId, - ...this.computeContentAssistParams(context), - ...delta, - }); - if (isContentAssistResult(result)) { - return [result.stateId, result.entries]; - } - 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('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 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 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.newEmptyChangeDesc(); - let newStateId: string | null = null; - try { - 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; - 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/XtextWebSocketClient.ts b/language-web/src/main/js/editor/XtextWebSocketClient.ts deleted file mode 100644 index 5b775500..00000000 --- a/language-web/src/main/js/editor/XtextWebSocketClient.ts +++ /dev/null @@ -1,345 +0,0 @@ -import { nanoid } from 'nanoid'; - -import { getLogger } from '../logging'; -import { PendingTask } from '../utils/PendingTask'; -import { Timer } from '../utils/Timer'; -import { - isErrorResponse, - isOkResponse, - isPushMessage, - IXtextWebRequest, -} from './xtextMessages'; -import { isPongResult } 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('XtextWebSocketClient'); - -type ReconnectHandler = () => Promise; - -type PushHandler = ( - resourceId: string, - stateId: string, - service: string, - data: unknown, -) => Promise; - -enum State { - Initial, - Opening, - TabVisible, - TabHiddenIdle, - TabHiddenWaiting, - Error, - TimedOut, -} - -export class XtextWebSocketClient { - nextMessageId = 0; - - connection!: WebSocket; - - pendingRequests = new Map>(); - - onReconnect: ReconnectHandler; - - onPush: PushHandler; - - state = State.Initial; - - reconnectTryCount = 0; - - idleTimer = new Timer(() => { - this.handleIdleTimeout(); - }, BACKGROUND_IDLE_TIMEOUT_MS); - - pingTimer = new Timer(() => { - this.sendPing(); - }, PING_TIMEOUT_MS); - - 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().catch((error) => { - log.error('Unexpected error in onReconnect handler', error); - }); - }); - 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) => { - if (isPongResult(result) && result.pong === ping) { - log.trace('Pong', ping); - this.pingTimer.schedule(); - } else { - log.error('Invalid pong'); - 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 IXtextWebRequest); - 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; - } - if (isOkResponse(message)) { - this.resolveRequest(message.id, message.response); - } else if (isErrorResponse(message)) { - this.rejectRequest(message.id, new Error(`${message.error} error: ${message.message}`)); - if (message.error === 'server') { - log.error('Reconnecting due to server error: ', message.message); - this.forceReconnectOnError(); - } - } else if (isPushMessage(message)) { - this.onPush( - message.resource, - message.stateId, - message.service, - message.push, - ).catch((error) => { - log.error('Unexpected error in onPush handler', error); - }); - } else { - log.error('Unexpected websocket message', message); - 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/language-web/src/main/js/editor/folding.ts b/language-web/src/main/js/editor/folding.ts deleted file mode 100644 index 54c7294d..00000000 --- a/language-web/src/main/js/editor/folding.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { EditorState } from '@codemirror/state'; -import type { SyntaxNode } from '@lezer/common'; - -export type FoldRange = { from: number, to: number }; - -/** - * Folds a block comment between its delimiters. - * - * @param node the node to fold - * @returns the folding range or `null` is there is nothing to fold - */ -export function foldBlockComment(node: SyntaxNode): FoldRange { - return { - from: node.from + 2, - to: node.to - 2, - }; -} - -/** - * Folds a declaration after the first element if it appears on the opening line, - * otherwise folds after the opening keyword. - * - * @example - * First element on the opening line: - * ``` - * scope Family = 1, - * Person += 5..10. - * ``` - * becomes - * ``` - * scope Family = 1,[...]. - * ``` - * - * @example - * First element not on the opening line: - * ``` - * scope Family - * = 1, - * Person += 5..10. - * ``` - * becomes - * ``` - * scope [...]. - * ``` - * - * @param node the node to fold - * @param state the editor state - * @returns the folding range or `null` is there is nothing to fold - */ -export function foldDeclaration(node: SyntaxNode, state: EditorState): FoldRange | null { - const { firstChild: open, lastChild: close } = node; - if (open === null || close === null) { - return null; - } - const { cursor } = open; - const lineEnd = state.doc.lineAt(open.from).to; - let foldFrom = open.to; - while (cursor.next() && cursor.from < lineEnd) { - if (cursor.type.name === ',') { - foldFrom = cursor.to; - break; - } - } - return { - from: foldFrom, - to: close.from, - }; -} - -/** - * Folds a node only if it has at least one sibling of the same type. - * - * The folding range will be the entire `node`. - * - * @param node the node to fold - * @returns the folding range or `null` is there is nothing to fold - */ -export function foldConjunction(node: SyntaxNode): FoldRange | null { - const { parent } = node; - if (parent === null) { - return null; - } - const { cursor } = parent; - let nConjunctions = 0; - while (cursor.next()) { - if (cursor.type === node.type) { - nConjunctions += 1; - } - if (nConjunctions >= 2) { - return { - from: node.from, - to: node.to, - }; - } - } - return null; -} diff --git a/language-web/src/main/js/editor/indentation.ts b/language-web/src/main/js/editor/indentation.ts deleted file mode 100644 index b2f0134b..00000000 --- a/language-web/src/main/js/editor/indentation.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { TreeIndentContext } from '@codemirror/language'; - -/** - * Finds the `from` of first non-skipped token, if any, - * after the opening keyword in the first line of the declaration. - * - * Based on - * https://github.com/codemirror/language/blob/cd7f7e66fa51ddbce96cf9396b1b6127d0ca4c94/src/indent.ts#L246 - * - * @param context the indentation context - * @returns the alignment or `null` if there is no token after the opening keyword - */ -function findAlignmentAfterOpening(context: TreeIndentContext): number | null { - const { - node: tree, - simulatedBreak, - } = context; - const openingToken = tree.childAfter(tree.from); - if (openingToken === null) { - return null; - } - const openingLine = context.state.doc.lineAt(openingToken.from); - const lineEnd = simulatedBreak == null || simulatedBreak <= openingLine.from - ? openingLine.to - : Math.min(openingLine.to, simulatedBreak); - const { cursor } = openingToken; - while (cursor.next() && cursor.from < lineEnd) { - if (!cursor.type.isSkipped) { - return cursor.from; - } - } - return null; -} - -/** - * Indents text after declarations by a single unit if it begins on a new line, - * otherwise it aligns with the text after the declaration. - * - * Based on - * https://github.com/codemirror/language/blob/cd7f7e66fa51ddbce96cf9396b1b6127d0ca4c94/src/indent.ts#L275 - * - * @example - * Result with no hanging indent (indent unit = 2 spaces, units = 1): - * ``` - * scope - * Family = 1, - * Person += 5..10. - * ``` - * - * @example - * Result with hanging indent: - * ``` - * scope Family = 1, - * Person += 5..10. - * ``` - * - * @param context the indentation context - * @param units the number of units to indent - * @returns the desired indentation level - */ -function indentDeclarationStrategy(context: TreeIndentContext, units: number): number { - const alignment = findAlignmentAfterOpening(context); - if (alignment !== null) { - return context.column(alignment); - } - return context.baseIndent + units * context.unit; -} - -export function indentBlockComment(): number { - // Do not indent. - return -1; -} - -export function indentDeclaration(context: TreeIndentContext): number { - return indentDeclarationStrategy(context, 1); -} - -export function indentPredicate(context: TreeIndentContext): number { - const clauseIndent = indentDeclarationStrategy(context, 1); - if (/^\s+(;|\.)/.exec(context.textAfter) !== null) { - return clauseIndent - context.unit; - } - return clauseIndent; -} diff --git a/language-web/src/main/js/editor/problem.grammar b/language-web/src/main/js/editor/problem.grammar deleted file mode 100644 index cf940698..00000000 --- a/language-web/src/main/js/editor/problem.grammar +++ /dev/null @@ -1,129 +0,0 @@ -@top Problem { statement* } - -statement { - ProblemDeclaration { - ckw<"problem"> QualifiedName "." - } | - ClassDefinition { - ckw<"abstract">? ckw<"class"> RelationName - (ckw<"extends"> sep<",", RelationName>)? - (ClassBody { "{" ReferenceDeclaration* "}" } | ".") - } | - EnumDefinition { - ckw<"enum"> RelationName - (EnumBody { "{" sep<",", UniqueNodeName> "}" } | ".") - } | - PredicateDefinition { - (ckw<"error"> ckw<"pred">? | ckw<"pred">) RelationName ParameterList? - PredicateBody { ("<->" sep)? "." } - } | - Assertion { - ckw<"default">? (NotOp | UnknownOp)? RelationName - ParameterList (":" LogicValue)? "." - } | - NodeValueAssertion { - UniqueNodeName ":" Constant "." - } | - UniqueDeclaration { - ckw<"unique"> sep<",", UniqueNodeName> "." - } | - ScopeDeclaration { - ckw<"scope"> sep<",", ScopeElement> "." - } -} - -ReferenceDeclaration { - (kw<"refers"> | kw<"contains">)? - RelationName - RelationName - ( "[" Multiplicity? "]" )? - (kw<"opposite"> RelationName)? - ";"? -} - -Parameter { RelationName? VariableName } - -Conjunction { ("," | Literal)+ } - -OrOp { ";" } - -Literal { NotOp? Atom } - -Atom { RelationName ParameterList? } - -Argument { VariableName | Constant } - -AssertionArgument { NodeName | StarArgument | Constant } - -Constant { Real | String } - -LogicValue { - ckw<"true"> | ckw<"false"> | ckw<"unknown"> | ckw<"error"> -} - -ScopeElement { RelationName ("=" | "+=") Multiplicity } - -Multiplicity { (IntMult "..")? (IntMult | StarMult)} - -RelationName { QualifiedName } - -UniqueNodeName { QualifiedName } - -VariableName { QualifiedName } - -NodeName { QualifiedName } - -QualifiedName { identifier ("::" identifier)* } - -kw { @specialize[@name={term}] } - -ckw { @extend[@name={term}] } - -ParameterList { "(" sep<",", content> ")" } - -sep { sep1? } - -sep1 { content (separator content?)* } - -@skip { LineComment | BlockComment | whitespace } - -@tokens { - whitespace { std.whitespace+ } - - LineComment { ("//" | "%") ![\n]* } - - BlockComment { "/*" blockCommentRest } - - blockCommentRest { ![*] blockCommentRest | "*" blockCommentAfterStar } - - blockCommentAfterStar { "/" | "*" blockCommentAfterStar | ![/*] blockCommentRest } - - @precedence { BlockComment, LineComment } - - identifier { $[A-Za-z_] $[a-zA-Z0-9_]* } - - int { $[0-9]+ } - - IntMult { int } - - StarMult { "*" } - - Real { "-"? (exponential | int ("." (int | exponential))?) } - - exponential { int ("e" | "E") ("+" | "-")? int } - - String { - "'" (![\\'\n] | "\\" ![\n] | "\\\n")+ "'" | - "\"" (![\\"\n] | "\\" (![\n] | "\n"))* "\"" - } - - NotOp { "!" } - - UnknownOp { "?" } - - StarArgument { "*" } - - "{" "}" "(" ")" "[" "]" "." ".." "," ":" "<->" -} - -@detectDelim diff --git a/language-web/src/main/js/editor/problemLanguageSupport.ts b/language-web/src/main/js/editor/problemLanguageSupport.ts deleted file mode 100644 index c9e61b31..00000000 --- a/language-web/src/main/js/editor/problemLanguageSupport.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { styleTags, tags as t } from '@codemirror/highlight'; -import { - foldInside, - foldNodeProp, - indentNodeProp, - LanguageSupport, - LRLanguage, -} from '@codemirror/language'; -import { LRParser } from '@lezer/lr'; - -import { parser } from '../../../../build/generated/sources/lezer/problem'; -import { - foldBlockComment, - foldConjunction, - foldDeclaration, -} from './folding'; -import { - indentBlockComment, - indentDeclaration, - indentPredicate, -} from './indentation'; - -const parserWithMetadata = (parser as LRParser).configure({ - props: [ - styleTags({ - LineComment: t.lineComment, - BlockComment: t.blockComment, - 'problem class enum pred unique scope': t.definitionKeyword, - 'abstract extends refers contains opposite error default': t.modifier, - 'true false unknown error': t.keyword, - NotOp: t.keyword, - UnknownOp: t.keyword, - OrOp: t.keyword, - StarArgument: t.keyword, - 'IntMult StarMult Real': t.number, - StarMult: t.number, - String: t.string, - 'RelationName/QualifiedName': t.typeName, - 'UniqueNodeName/QualifiedName': t.atom, - 'VariableName/QualifiedName': t.variableName, - '{ }': t.brace, - '( )': t.paren, - '[ ]': t.squareBracket, - '. .. , :': t.separator, - '<->': t.definitionOperator, - }), - indentNodeProp.add({ - ProblemDeclaration: indentDeclaration, - UniqueDeclaration: indentDeclaration, - ScopeDeclaration: indentDeclaration, - PredicateBody: indentPredicate, - BlockComment: indentBlockComment, - }), - foldNodeProp.add({ - ClassBody: foldInside, - EnumBody: foldInside, - ParameterList: foldInside, - PredicateBody: foldInside, - Conjunction: foldConjunction, - UniqueDeclaration: foldDeclaration, - ScopeDeclaration: foldDeclaration, - BlockComment: foldBlockComment, - }), - ], -}); - -const problemLanguage = LRLanguage.define({ - parser: parserWithMetadata, - languageData: { - commentTokens: { - block: { - open: '/*', - close: '*/', - }, - line: '%', - }, - indentOnInput: /^\s*(?:\{|\}|\(|\)|;|\.)$/, - }, -}); - -export function problemLanguageSupport(): LanguageSupport { - return new LanguageSupport(problemLanguage); -} diff --git a/language-web/src/main/js/editor/xtextMessages.ts b/language-web/src/main/js/editor/xtextMessages.ts deleted file mode 100644 index 68737958..00000000 --- a/language-web/src/main/js/editor/xtextMessages.ts +++ /dev/null @@ -1,62 +0,0 @@ -export interface IXtextWebRequest { - id: string; - - request: unknown; -} - -export interface IXtextWebOkResponse { - id: string; - - response: unknown; -} - -export function isOkResponse(response: unknown): response is IXtextWebOkResponse { - const okResponse = response as IXtextWebOkResponse; - return typeof okResponse === 'object' - && typeof okResponse.id === 'string' - && typeof okResponse.response !== 'undefined'; -} - -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; - - error: XtextWebErrorKind; - - message: string; -} - -export function isErrorResponse(response: unknown): response is IXtextWebErrorResponse { - const errorResponse = response as IXtextWebErrorResponse; - return typeof errorResponse === 'object' - && typeof errorResponse.id === 'string' - && isXtextWebErrorKind(errorResponse.error) - && typeof errorResponse.message === 'string'; -} - -export interface IXtextWebPushMessage { - resource: string; - - stateId: string; - - service: string; - - push: unknown; -} - -export function isPushMessage(response: unknown): response is IXtextWebPushMessage { - const pushMessage = response as IXtextWebPushMessage; - return typeof pushMessage === 'object' - && typeof pushMessage.resource === 'string' - && typeof pushMessage.stateId === 'string' - && typeof pushMessage.service === 'string' - && typeof pushMessage.push !== 'undefined'; -} diff --git a/language-web/src/main/js/editor/xtextServiceResults.ts b/language-web/src/main/js/editor/xtextServiceResults.ts deleted file mode 100644 index 6c3d9daf..00000000 --- a/language-web/src/main/js/editor/xtextServiceResults.ts +++ /dev/null @@ -1,200 +0,0 @@ -export interface IPongResult { - pong: string; -} - -export function isPongResult(result: unknown): result is IPongResult { - const pongResult = result as IPongResult; - return typeof pongResult === 'object' - && typeof pongResult.pong === 'string'; -} - -export interface IDocumentStateResult { - stateId: string; -} - -export function isDocumentStateResult(result: unknown): result is IDocumentStateResult { - const documentStateResult = result as IDocumentStateResult; - return typeof documentStateResult === 'object' - && typeof documentStateResult.stateId === 'string'; -} - -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 === '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; - - severity: Severity; - - line: number; - - column: number; - - offset: number; - - length: number; -} - -export function isIssue(value: unknown): value is IIssue { - const issue = value as IIssue; - return typeof issue === 'object' - && typeof issue.description === 'string' - && isSeverity(issue.severity) - && typeof issue.line === 'number' - && typeof issue.column === 'number' - && typeof issue.offset === 'number' - && typeof issue.length === 'number'; -} - -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' - && 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); -} diff --git a/language-web/src/main/js/language/folding.ts b/language-web/src/main/js/language/folding.ts new file mode 100644 index 00000000..54c7294d --- /dev/null +++ b/language-web/src/main/js/language/folding.ts @@ -0,0 +1,97 @@ +import { EditorState } from '@codemirror/state'; +import type { SyntaxNode } from '@lezer/common'; + +export type FoldRange = { from: number, to: number }; + +/** + * Folds a block comment between its delimiters. + * + * @param node the node to fold + * @returns the folding range or `null` is there is nothing to fold + */ +export function foldBlockComment(node: SyntaxNode): FoldRange { + return { + from: node.from + 2, + to: node.to - 2, + }; +} + +/** + * Folds a declaration after the first element if it appears on the opening line, + * otherwise folds after the opening keyword. + * + * @example + * First element on the opening line: + * ``` + * scope Family = 1, + * Person += 5..10. + * ``` + * becomes + * ``` + * scope Family = 1,[...]. + * ``` + * + * @example + * First element not on the opening line: + * ``` + * scope Family + * = 1, + * Person += 5..10. + * ``` + * becomes + * ``` + * scope [...]. + * ``` + * + * @param node the node to fold + * @param state the editor state + * @returns the folding range or `null` is there is nothing to fold + */ +export function foldDeclaration(node: SyntaxNode, state: EditorState): FoldRange | null { + const { firstChild: open, lastChild: close } = node; + if (open === null || close === null) { + return null; + } + const { cursor } = open; + const lineEnd = state.doc.lineAt(open.from).to; + let foldFrom = open.to; + while (cursor.next() && cursor.from < lineEnd) { + if (cursor.type.name === ',') { + foldFrom = cursor.to; + break; + } + } + return { + from: foldFrom, + to: close.from, + }; +} + +/** + * Folds a node only if it has at least one sibling of the same type. + * + * The folding range will be the entire `node`. + * + * @param node the node to fold + * @returns the folding range or `null` is there is nothing to fold + */ +export function foldConjunction(node: SyntaxNode): FoldRange | null { + const { parent } = node; + if (parent === null) { + return null; + } + const { cursor } = parent; + let nConjunctions = 0; + while (cursor.next()) { + if (cursor.type === node.type) { + nConjunctions += 1; + } + if (nConjunctions >= 2) { + return { + from: node.from, + to: node.to, + }; + } + } + return null; +} diff --git a/language-web/src/main/js/language/indentation.ts b/language-web/src/main/js/language/indentation.ts new file mode 100644 index 00000000..b2f0134b --- /dev/null +++ b/language-web/src/main/js/language/indentation.ts @@ -0,0 +1,84 @@ +import { TreeIndentContext } from '@codemirror/language'; + +/** + * Finds the `from` of first non-skipped token, if any, + * after the opening keyword in the first line of the declaration. + * + * Based on + * https://github.com/codemirror/language/blob/cd7f7e66fa51ddbce96cf9396b1b6127d0ca4c94/src/indent.ts#L246 + * + * @param context the indentation context + * @returns the alignment or `null` if there is no token after the opening keyword + */ +function findAlignmentAfterOpening(context: TreeIndentContext): number | null { + const { + node: tree, + simulatedBreak, + } = context; + const openingToken = tree.childAfter(tree.from); + if (openingToken === null) { + return null; + } + const openingLine = context.state.doc.lineAt(openingToken.from); + const lineEnd = simulatedBreak == null || simulatedBreak <= openingLine.from + ? openingLine.to + : Math.min(openingLine.to, simulatedBreak); + const { cursor } = openingToken; + while (cursor.next() && cursor.from < lineEnd) { + if (!cursor.type.isSkipped) { + return cursor.from; + } + } + return null; +} + +/** + * Indents text after declarations by a single unit if it begins on a new line, + * otherwise it aligns with the text after the declaration. + * + * Based on + * https://github.com/codemirror/language/blob/cd7f7e66fa51ddbce96cf9396b1b6127d0ca4c94/src/indent.ts#L275 + * + * @example + * Result with no hanging indent (indent unit = 2 spaces, units = 1): + * ``` + * scope + * Family = 1, + * Person += 5..10. + * ``` + * + * @example + * Result with hanging indent: + * ``` + * scope Family = 1, + * Person += 5..10. + * ``` + * + * @param context the indentation context + * @param units the number of units to indent + * @returns the desired indentation level + */ +function indentDeclarationStrategy(context: TreeIndentContext, units: number): number { + const alignment = findAlignmentAfterOpening(context); + if (alignment !== null) { + return context.column(alignment); + } + return context.baseIndent + units * context.unit; +} + +export function indentBlockComment(): number { + // Do not indent. + return -1; +} + +export function indentDeclaration(context: TreeIndentContext): number { + return indentDeclarationStrategy(context, 1); +} + +export function indentPredicate(context: TreeIndentContext): number { + const clauseIndent = indentDeclarationStrategy(context, 1); + if (/^\s+(;|\.)/.exec(context.textAfter) !== null) { + return clauseIndent - context.unit; + } + return clauseIndent; +} diff --git a/language-web/src/main/js/language/problem.grammar b/language-web/src/main/js/language/problem.grammar new file mode 100644 index 00000000..cf940698 --- /dev/null +++ b/language-web/src/main/js/language/problem.grammar @@ -0,0 +1,129 @@ +@top Problem { statement* } + +statement { + ProblemDeclaration { + ckw<"problem"> QualifiedName "." + } | + ClassDefinition { + ckw<"abstract">? ckw<"class"> RelationName + (ckw<"extends"> sep<",", RelationName>)? + (ClassBody { "{" ReferenceDeclaration* "}" } | ".") + } | + EnumDefinition { + ckw<"enum"> RelationName + (EnumBody { "{" sep<",", UniqueNodeName> "}" } | ".") + } | + PredicateDefinition { + (ckw<"error"> ckw<"pred">? | ckw<"pred">) RelationName ParameterList? + PredicateBody { ("<->" sep)? "." } + } | + Assertion { + ckw<"default">? (NotOp | UnknownOp)? RelationName + ParameterList (":" LogicValue)? "." + } | + NodeValueAssertion { + UniqueNodeName ":" Constant "." + } | + UniqueDeclaration { + ckw<"unique"> sep<",", UniqueNodeName> "." + } | + ScopeDeclaration { + ckw<"scope"> sep<",", ScopeElement> "." + } +} + +ReferenceDeclaration { + (kw<"refers"> | kw<"contains">)? + RelationName + RelationName + ( "[" Multiplicity? "]" )? + (kw<"opposite"> RelationName)? + ";"? +} + +Parameter { RelationName? VariableName } + +Conjunction { ("," | Literal)+ } + +OrOp { ";" } + +Literal { NotOp? Atom } + +Atom { RelationName ParameterList? } + +Argument { VariableName | Constant } + +AssertionArgument { NodeName | StarArgument | Constant } + +Constant { Real | String } + +LogicValue { + ckw<"true"> | ckw<"false"> | ckw<"unknown"> | ckw<"error"> +} + +ScopeElement { RelationName ("=" | "+=") Multiplicity } + +Multiplicity { (IntMult "..")? (IntMult | StarMult)} + +RelationName { QualifiedName } + +UniqueNodeName { QualifiedName } + +VariableName { QualifiedName } + +NodeName { QualifiedName } + +QualifiedName { identifier ("::" identifier)* } + +kw { @specialize[@name={term}] } + +ckw { @extend[@name={term}] } + +ParameterList { "(" sep<",", content> ")" } + +sep { sep1? } + +sep1 { content (separator content?)* } + +@skip { LineComment | BlockComment | whitespace } + +@tokens { + whitespace { std.whitespace+ } + + LineComment { ("//" | "%") ![\n]* } + + BlockComment { "/*" blockCommentRest } + + blockCommentRest { ![*] blockCommentRest | "*" blockCommentAfterStar } + + blockCommentAfterStar { "/" | "*" blockCommentAfterStar | ![/*] blockCommentRest } + + @precedence { BlockComment, LineComment } + + identifier { $[A-Za-z_] $[a-zA-Z0-9_]* } + + int { $[0-9]+ } + + IntMult { int } + + StarMult { "*" } + + Real { "-"? (exponential | int ("." (int | exponential))?) } + + exponential { int ("e" | "E") ("+" | "-")? int } + + String { + "'" (![\\'\n] | "\\" ![\n] | "\\\n")+ "'" | + "\"" (![\\"\n] | "\\" (![\n] | "\n"))* "\"" + } + + NotOp { "!" } + + UnknownOp { "?" } + + StarArgument { "*" } + + "{" "}" "(" ")" "[" "]" "." ".." "," ":" "<->" +} + +@detectDelim diff --git a/language-web/src/main/js/language/problemLanguageSupport.ts b/language-web/src/main/js/language/problemLanguageSupport.ts new file mode 100644 index 00000000..c9e61b31 --- /dev/null +++ b/language-web/src/main/js/language/problemLanguageSupport.ts @@ -0,0 +1,83 @@ +import { styleTags, tags as t } from '@codemirror/highlight'; +import { + foldInside, + foldNodeProp, + indentNodeProp, + LanguageSupport, + LRLanguage, +} from '@codemirror/language'; +import { LRParser } from '@lezer/lr'; + +import { parser } from '../../../../build/generated/sources/lezer/problem'; +import { + foldBlockComment, + foldConjunction, + foldDeclaration, +} from './folding'; +import { + indentBlockComment, + indentDeclaration, + indentPredicate, +} from './indentation'; + +const parserWithMetadata = (parser as LRParser).configure({ + props: [ + styleTags({ + LineComment: t.lineComment, + BlockComment: t.blockComment, + 'problem class enum pred unique scope': t.definitionKeyword, + 'abstract extends refers contains opposite error default': t.modifier, + 'true false unknown error': t.keyword, + NotOp: t.keyword, + UnknownOp: t.keyword, + OrOp: t.keyword, + StarArgument: t.keyword, + 'IntMult StarMult Real': t.number, + StarMult: t.number, + String: t.string, + 'RelationName/QualifiedName': t.typeName, + 'UniqueNodeName/QualifiedName': t.atom, + 'VariableName/QualifiedName': t.variableName, + '{ }': t.brace, + '( )': t.paren, + '[ ]': t.squareBracket, + '. .. , :': t.separator, + '<->': t.definitionOperator, + }), + indentNodeProp.add({ + ProblemDeclaration: indentDeclaration, + UniqueDeclaration: indentDeclaration, + ScopeDeclaration: indentDeclaration, + PredicateBody: indentPredicate, + BlockComment: indentBlockComment, + }), + foldNodeProp.add({ + ClassBody: foldInside, + EnumBody: foldInside, + ParameterList: foldInside, + PredicateBody: foldInside, + Conjunction: foldConjunction, + UniqueDeclaration: foldDeclaration, + ScopeDeclaration: foldDeclaration, + BlockComment: foldBlockComment, + }), + ], +}); + +const problemLanguage = LRLanguage.define({ + parser: parserWithMetadata, + languageData: { + commentTokens: { + block: { + open: '/*', + close: '*/', + }, + line: '%', + }, + indentOnInput: /^\s*(?:\{|\}|\(|\)|;|\.)$/, + }, +}); + +export function problemLanguageSupport(): LanguageSupport { + return new LanguageSupport(problemLanguage); +} diff --git a/language-web/src/main/js/xtext/CodeMirrorEditorContext.js b/language-web/src/main/js/xtext/CodeMirrorEditorContext.js deleted file mode 100644 index b829c680..00000000 --- a/language-web/src/main/js/xtext/CodeMirrorEditorContext.js +++ /dev/null @@ -1,111 +0,0 @@ -/******************************************************************************* - * Copyright (c) 2015 itemis AG (http://www.itemis.eu) and others. - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0. - * - * SPDX-License-Identifier: EPL-2.0 - *******************************************************************************/ - -define([], function() { - - /** - * An editor context mediates between the Xtext services and the CodeMirror editor framework. - */ - function CodeMirrorEditorContext(editor) { - this._editor = editor; - this._serverState = {}; - this._serverStateListeners = []; - this._dirty = false; - this._dirtyStateListeners = []; - }; - - CodeMirrorEditorContext.prototype = { - - getServerState: function() { - return this._serverState; - }, - - updateServerState: function(currentText, currentStateId) { - this._serverState.text = currentText; - this._serverState.stateId = currentStateId; - return this._serverStateListeners; - }, - - addServerStateListener: function(listener) { - this._serverStateListeners.push(listener); - }, - - getCaretOffset: function() { - var editor = this._editor; - return editor.indexFromPos(editor.getCursor()); - }, - - getLineStart: function(lineNumber) { - var editor = this._editor; - return editor.indexFromPos({line: lineNumber, ch: 0}); - }, - - getSelection: function() { - var editor = this._editor; - return { - start: editor.indexFromPos(editor.getCursor('from')), - end: editor.indexFromPos(editor.getCursor('to')) - }; - }, - - getText: function(start, end) { - var editor = this._editor; - if (start && end) { - return editor.getRange(editor.posFromIndex(start), editor.posFromIndex(end)); - } else { - return editor.getValue(); - } - }, - - isDirty: function() { - return !this._clean; - }, - - setDirty: function(dirty) { - if (dirty != this._dirty) { - for (var i = 0; i < this._dirtyStateListeners.length; i++) { - this._dirtyStateListeners[i](dirty); - } - } - this._dirty = dirty; - }, - - addDirtyStateListener: function(listener) { - this._dirtyStateListeners.push(listener); - }, - - clearUndoStack: function() { - this._editor.clearHistory(); - }, - - setCaretOffset: function(offset) { - var editor = this._editor; - editor.setCursor(editor.posFromIndex(offset)); - }, - - setSelection: function(selection) { - var editor = this._editor; - editor.setSelection(editor.posFromIndex(selection.start), editor.posFromIndex(selection.end)); - }, - - setText: function(text, start, end) { - var editor = this._editor; - if (!start) - start = 0; - if (!end) - end = editor.getValue().length; - var cursor = editor.getCursor(); - editor.replaceRange(text, editor.posFromIndex(start), editor.posFromIndex(end)); - editor.setCursor(cursor); - } - - }; - - return CodeMirrorEditorContext; -}); \ No newline at end of file diff --git a/language-web/src/main/js/xtext/ContentAssistService.ts b/language-web/src/main/js/xtext/ContentAssistService.ts new file mode 100644 index 00000000..91789864 --- /dev/null +++ b/language-web/src/main/js/xtext/ContentAssistService.ts @@ -0,0 +1,133 @@ +import type { + Completion, + CompletionContext, + CompletionResult, +} from '@codemirror/autocomplete'; +import type { ChangeSet, Transaction } from '@codemirror/state'; + +import { getLogger } from '../logging'; +import type { UpdateService } from './UpdateService'; + +const log = getLogger('xtext.ContentAssistService'); + +export class ContentAssistService { + updateService: UpdateService; + + lastCompletion: CompletionResult | null = null; + + constructor(updateService: UpdateService) { + this.updateService = updateService; + } + + onTransaction(transaction: Transaction): void { + if (this.shouldInvalidateCachedCompletion(transaction.changes)) { + this.lastCompletion = null; + } + } + + async contentAssist(context: CompletionContext): Promise { + const tokenBefore = context.tokenBefore(['QualifiedName']); + let range: { from: number, to: number }; + let selection: { selectionStart?: number, selectionEnd?: number }; + if (tokenBefore === null) { + if (!context.explicit) { + return { + from: context.pos, + options: [], + }; + } + range = { + from: context.pos, + to: context.pos, + }; + selection = {}; + } else { + range = { + from: tokenBefore.from, + to: tokenBefore.to, + }; + selection = { + selectionStart: tokenBefore.from, + selectionEnd: tokenBefore.to, + }; + } + 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, + ...selection, + }, 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, transformedTo] = this.mapRangeInclusive(lastFrom, lastTo); + return from >= transformedFrom && to <= transformedTo && span && span.exec(text); + } + + private shouldInvalidateCachedCompletion(changes: ChangeSet) { + if (changes.empty || 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; + 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/language-web/src/main/js/xtext/ServiceBuilder.js b/language-web/src/main/js/xtext/ServiceBuilder.js deleted file mode 100644 index 57fcb310..00000000 --- a/language-web/src/main/js/xtext/ServiceBuilder.js +++ /dev/null @@ -1,285 +0,0 @@ -/******************************************************************************* - * Copyright (c) 2015 itemis AG (http://www.itemis.eu) and others. - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0. - * - * SPDX-License-Identifier: EPL-2.0 - ******************************************************************************/ - -define([ - 'jquery', - 'xtext/services/XtextService', - 'xtext/services/LoadResourceService', - 'xtext/services/SaveResourceService', - 'xtext/services/HighlightingService', - 'xtext/services/ValidationService', - 'xtext/services/UpdateService', - 'xtext/services/ContentAssistService', - 'xtext/services/HoverService', - 'xtext/services/OccurrencesService', - 'xtext/services/FormattingService', - '../logging', -], function(jQuery, XtextService, LoadResourceService, SaveResourceService, HighlightingService, - ValidationService, UpdateService, ContentAssistService, HoverService, OccurrencesService, - FormattingService, logging) { - - /** - * Builder class for the Xtext services. - */ - function ServiceBuilder(xtextServices) { - this.services = xtextServices; - }; - - /** - * Create all the available Xtext services depending on the configuration. - */ - ServiceBuilder.prototype.createServices = function() { - var services = this.services; - var options = services.options; - var editorContext = services.editorContext; - editorContext.xtextServices = services; - var self = this; - if (!options.serviceUrl) { - if (!options.baseUrl) - options.baseUrl = '/'; - else if (options.baseUrl.charAt(0) != '/') - options.baseUrl = '/' + options.baseUrl; - options.serviceUrl = window.location.protocol + '//' + window.location.host + options.baseUrl + 'xtext-service'; - } - if (options.resourceId) { - if (!options.xtextLang) - options.xtextLang = options.resourceId.split(/[?#]/)[0].split('.').pop(); - if (options.loadFromServer === undefined) - options.loadFromServer = true; - if (options.loadFromServer && this.setupPersistenceServices) { - services.loadResourceService = new LoadResourceService(options.serviceUrl, options.resourceId, false); - services.loadResource = function(addParams) { - return services.loadResourceService.invoke(editorContext, ServiceBuilder.mergeOptions(addParams, options)); - } - services.saveResourceService = new SaveResourceService(options.serviceUrl, options.resourceId); - services.saveResource = function(addParams) { - return services.saveResourceService.invoke(editorContext, ServiceBuilder.mergeOptions(addParams, options)); - } - services.revertResourceService = new LoadResourceService(options.serviceUrl, options.resourceId, true); - services.revertResource = function(addParams) { - return services.revertResourceService.invoke(editorContext, ServiceBuilder.mergeOptions(addParams, options)); - } - this.setupPersistenceServices(); - services.loadResource(); - } - } else { - if (options.loadFromServer === undefined) - options.loadFromServer = false; - if (options.xtextLang) { - var randomId = Math.floor(Math.random() * 2147483648).toString(16); - options.resourceId = randomId + '.' + options.xtextLang; - } - } - - if (this.setupSyntaxHighlighting) { - this.setupSyntaxHighlighting(); - } - if (options.enableHighlightingService || options.enableHighlightingService === undefined) { - services.highlightingService = new HighlightingService(options.serviceUrl, options.resourceId); - services.computeHighlighting = function(addParams) { - return services.highlightingService.invoke(editorContext, ServiceBuilder.mergeOptions(addParams, options)); - } - } - if (options.enableValidationService || options.enableValidationService === undefined) { - services.validationService = new ValidationService(options.serviceUrl, options.resourceId); - services.validate = function(addParams) { - return services.validationService.invoke(editorContext, ServiceBuilder.mergeOptions(addParams, options)); - } - } - if (this.setupUpdateService) { - function refreshDocument() { - if (services.highlightingService && self.doHighlighting) { - services.highlightingService.setState(undefined); - self.doHighlighting(); - } - if (services.validationService && self.doValidation) { - services.validationService.setState(undefined); - self.doValidation(); - } - } - if (!options.sendFullText) { - services.updateService = new UpdateService(options.serviceUrl, options.resourceId); - services.update = function(addParams) { - return services.updateService.invoke(editorContext, ServiceBuilder.mergeOptions(addParams, options)); - } - if (services.saveResourceService) - services.saveResourceService._updateService = services.updateService; - editorContext.addServerStateListener(refreshDocument); - } - this.setupUpdateService(refreshDocument); - } - if ((options.enableContentAssistService || options.enableContentAssistService === undefined) - && this.setupContentAssistService) { - services.contentAssistService = new ContentAssistService(options.serviceUrl, options.resourceId, services.updateService); - services.getContentAssist = function(addParams) { - return services.contentAssistService.invoke(editorContext, ServiceBuilder.mergeOptions(addParams, options)); - } - this.setupContentAssistService(); - } - if ((options.enableHoverService || options.enableHoverService === undefined) - && this.setupHoverService) { - services.hoverService = new HoverService(options.serviceUrl, options.resourceId, services.updateService); - services.getHoverInfo = function(addParams) { - return services.hoverService.invoke(editorContext, ServiceBuilder.mergeOptions(addParams, options)); - } - this.setupHoverService(); - } - if ((options.enableOccurrencesService || options.enableOccurrencesService === undefined) - && this.setupOccurrencesService) { - services.occurrencesService = new OccurrencesService(options.serviceUrl, options.resourceId, services.updateService); - services.getOccurrences = function(addParams) { - return services.occurrencesService.invoke(editorContext, ServiceBuilder.mergeOptions(addParams, options)); - } - this.setupOccurrencesService(); - } - if ((options.enableFormattingService || options.enableFormattingService === undefined) - && this.setupFormattingService) { - services.formattingService = new FormattingService(options.serviceUrl, options.resourceId, services.updateService); - services.format = function(addParams) { - return services.formattingService.invoke(editorContext, ServiceBuilder.mergeOptions(addParams, options)); - } - this.setupFormattingService(); - } - if (options.enableGeneratorService || options.enableGeneratorService === undefined) { - services.generatorService = new XtextService(); - services.generatorService.initialize(services, 'generate'); - services.generatorService._initServerData = function(serverData, editorContext, params) { - if (params.allArtifacts) - serverData.allArtifacts = params.allArtifacts; - else if (params.artifactId) - serverData.artifact = params.artifactId; - if (params.includeContent !== undefined) - serverData.includeContent = params.includeContent; - } - services.generate = function(addParams) { - return services.generatorService.invoke(editorContext, ServiceBuilder.mergeOptions(addParams, options)); - } - } - - if (options.dirtyElement) { - var doc = options.document || document; - var dirtyElement; - if (typeof(options.dirtyElement) === 'string') - dirtyElement = jQuery('#' + options.dirtyElement, doc); - else - dirtyElement = jQuery(options.dirtyElement); - var dirtyStatusClass = options.dirtyStatusClass; - if (!dirtyStatusClass) - dirtyStatusClass = 'dirty'; - editorContext.addDirtyStateListener(function(dirty) { - if (dirty) - dirtyElement.addClass(dirtyStatusClass); - else - dirtyElement.removeClass(dirtyStatusClass); - }); - } - - const log = logging.getLoggerFromRoot('xtext.XtextService'); - services.successListeners = [function(serviceType, result) { - if (log.getLevel() <= log.levels.TRACE) { - log.trace('service', serviceType, 'request success', JSON.parse(JSON.stringify(result))); - } - }]; - services.errorListeners = [function(serviceType, severity, message, requestData) { - const messageParts = ['service', serviceType, 'failed:', message || '(no message)']; - if (requestData) { - messageParts.push(JSON.parse(JSON.stringify(requestData))); - } - if (severity === 'warning') { - log.warn(...messageParts); - } else { - log.error(...messageParts); - } - }]; - } - - /** - * Change the resource associated with this service builder. - */ - ServiceBuilder.prototype.changeResource = function(resourceId) { - var services = this.services; - var options = services.options; - options.resourceId = resourceId; - for (var p in services) { - if (services.hasOwnProperty(p)) { - var service = services[p]; - if (service._serviceType && jQuery.isFunction(service.initialize)) - services[p].initialize(options.serviceUrl, service._serviceType, resourceId, services.updateService); - } - } - var knownServerState = services.editorContext.getServerState(); - delete knownServerState.stateId; - delete knownServerState.text; - if (options.loadFromServer && jQuery.isFunction(services.loadResource)) { - services.loadResource(); - } - } - - /** - * Create a copy of the given object. - */ - ServiceBuilder.copy = function(obj) { - var copy = {}; - for (var p in obj) { - if (obj.hasOwnProperty(p)) - copy[p] = obj[p]; - } - return copy; - } - - /** - * Translate an HTML attribute name to a JS option name. - */ - ServiceBuilder.optionName = function(name) { - var prefix = 'data-editor-'; - if (name.substring(0, prefix.length) === prefix) { - var key = name.substring(prefix.length); - key = key.replace(/-([a-z])/ig, function(all, character) { - return character.toUpperCase(); - }); - return key; - } - return undefined; - } - - /** - * Copy all default options into the given set of additional options. - */ - ServiceBuilder.mergeOptions = function(options, defaultOptions) { - if (options) { - for (var p in defaultOptions) { - if (defaultOptions.hasOwnProperty(p)) - options[p] = defaultOptions[p]; - } - return options; - } else { - return ServiceBuilder.copy(defaultOptions); - } - } - - /** - * Merge all properties of the given parent element with the given default options. - */ - ServiceBuilder.mergeParentOptions = function(parent, defaultOptions) { - var options = ServiceBuilder.copy(defaultOptions); - for (var attr, j = 0, attrs = parent.attributes, l = attrs.length; j < l; j++) { - attr = attrs.item(j); - var key = ServiceBuilder.optionName(attr.nodeName); - if (key) { - var value = attr.nodeValue; - if (value === 'true' || value === 'false') - value = value === 'true'; - options[key] = value; - } - } - return options; - } - - return ServiceBuilder; -}); diff --git a/language-web/src/main/js/xtext/UpdateService.ts b/language-web/src/main/js/xtext/UpdateService.ts new file mode 100644 index 00000000..f8ab7438 --- /dev/null +++ b/language-web/src/main/js/xtext/UpdateService.ts @@ -0,0 +1,271 @@ +import { + ChangeDesc, + ChangeSet, + Transaction, +} from '@codemirror/state'; +import { nanoid } from 'nanoid'; + +import type { EditorStore } from '../editor/EditorStore'; +import { getLogger } from '../logging'; +import type { XtextWebSocketClient } from './XtextWebSocketClient'; +import { PendingTask } from '../utils/PendingTask'; +import { Timer } from '../utils/Timer'; +import { + IContentAssistEntry, + isContentAssistResult, + isDocumentStateResult, + isInvalidStateIdConflictResult, +} from './xtextServiceResults'; + +const UPDATE_TIMEOUT_MS = 500; + +const WAIT_FOR_UPDATE_TIMEOUT_MS = 1000; + +const log = getLogger('xtext.UpdateService'); + +export interface IAbortSignal { + aborted: boolean; +} + +export class UpdateService { + resourceName: string; + + xtextStateId: string | null = null; + + private store: EditorStore; + + private pendingUpdate: ChangeDesc | null = null; + + private dirtyChanges: ChangeDesc; + + private webSocketClient: XtextWebSocketClient; + + private updateListeners: PendingTask[] = []; + + private idleUpdateTimer = new Timer(() => { + this.handleIdleUpdate(); + }, UPDATE_TIMEOUT_MS); + + constructor(store: EditorStore, webSocketClient: XtextWebSocketClient) { + this.resourceName = `${nanoid(7)}.problem`; + this.store = store; + this.dirtyChanges = this.newEmptyChangeDesc(); + this.webSocketClient = webSocketClient; + } + + onTransaction(transaction: Transaction): void { + const { changes } = transaction; + if (!changes.empty) { + this.dirtyChanges = this.dirtyChanges.composeDesc(changes.desc); + this.idleUpdateTimer.reschedule(); + } + } + + computeChangesSinceLastUpdate(): ChangeDesc { + return this.pendingUpdate?.composeDesc(this.dirtyChanges) || this.dirtyChanges; + } + + 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 newEmptyChangeDesc() { + const changeSet = ChangeSet.of([], this.store.state.doc.length); + return changeSet.desc; + } + + 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), + }); + 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', + requiredStateId: this.xtextStateId, + ...delta, + }); + if (isDocumentStateResult(result)) { + return [result.stateId, undefined]; + } + if (isInvalidStateIdConflictResult(result)) { + return this.doFallbackToUpdateFullText(); + } + log.error('Unexpected delta text update result:', result); + throw new Error('Delta text update failed'); + }); + } + + 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 fetchContentAssist( + params: Record, + signal: IAbortSignal, + ): Promise { + await this.prepareForDeltaUpdate(); + if (signal.aborted) { + return []; + } + const delta = this.computeDelta(); + if (delta === null) { + // Poscondition of `prepareForDeltaUpdate`: `xtextStateId !== null` + return this.doFetchContentAssist(params, this.xtextStateId as string); + } + log.trace('Editor delta', delta); + return this.withUpdate(async () => { + const result = await this.webSocketClient.send({ + ...params, + requiredStateId: this.xtextStateId, + ...delta, + }); + if (isContentAssistResult(result)) { + return [result.stateId, result.entries]; + } + if (isInvalidStateIdConflictResult(result)) { + const [newStateId] = await this.doFallbackToUpdateFullText(); + if (signal.aborted) { + return [newStateId, []]; + } + const entries = await this.doFetchContentAssist(params, newStateId); + return [newStateId, entries]; + } + log.error('Unextpected content assist result with delta update', result); + throw new Error('Unexpexted content assist result with delta update'); + }); + } + + private async doFetchContentAssist(params: Record, expectedStateId: string) { + const result = await this.webSocketClient.send({ + ...params, + requiredStateId: expectedStateId, + }); + if (isContentAssistResult(result) && result.stateId === expectedStateId) { + return result.entries; + } + log.error('Unexpected content assist result', result); + throw new Error('Unexpected content assist result'); + } + + 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 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.newEmptyChangeDesc(); + let newStateId: string | null = null; + try { + 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; + 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/xtext/ValidationService.ts b/language-web/src/main/js/xtext/ValidationService.ts new file mode 100644 index 00000000..838aa31e --- /dev/null +++ b/language-web/src/main/js/xtext/ValidationService.ts @@ -0,0 +1,40 @@ +import type { Diagnostic } from '@codemirror/lint'; + +import type { EditorStore } from '../editor/EditorStore'; +import { getLogger } from '../logging'; +import type { UpdateService } from './UpdateService'; +import { isValidationResult } from './xtextServiceResults'; + +const log = getLogger('xtext.ValidationService'); + +export class ValidationService { + private store: EditorStore; + + private updateService: UpdateService; + + constructor(store: EditorStore, updateService: UpdateService) { + this.store = store; + this.updateService = updateService; + } + + onPush(push: unknown): void { + if (!isValidationResult(push)) { + log.error('Invalid validation result', push); + return; + } + const allChanges = this.updateService.computeChangesSinceLastUpdate(); + const diagnostics: Diagnostic[] = []; + push.issues.forEach((issue) => { + if (issue.severity === 'ignore') { + return; + } + diagnostics.push({ + from: allChanges.mapPos(issue.offset), + to: allChanges.mapPos(issue.offset + issue.length), + severity: issue.severity, + message: issue.description, + }); + }); + this.store.updateDiagnostics(diagnostics); + } +} diff --git a/language-web/src/main/js/xtext/XtextClient.ts b/language-web/src/main/js/xtext/XtextClient.ts new file mode 100644 index 00000000..f8b06258 --- /dev/null +++ b/language-web/src/main/js/xtext/XtextClient.ts @@ -0,0 +1,73 @@ +import type { + CompletionContext, + CompletionResult, +} from '@codemirror/autocomplete'; +import type { Transaction } from '@codemirror/state'; + +import type { EditorStore } from '../editor/EditorStore'; +import { ContentAssistService } from './ContentAssistService'; +import { getLogger } from '../logging'; +import { UpdateService } from './UpdateService'; +import { ValidationService } from './ValidationService'; +import { XtextWebSocketClient } from './XtextWebSocketClient'; + +const log = getLogger('xtext.XtextClient'); + +export class XtextClient { + webSocketClient: XtextWebSocketClient; + + updateService: UpdateService; + + contentAssistService: ContentAssistService; + + validationService: ValidationService; + + constructor(store: EditorStore) { + this.webSocketClient = new XtextWebSocketClient( + async () => { + this.updateService.xtextStateId = null; + await this.updateService.updateFullText(); + }, + async (resource, stateId, service, push) => { + await this.onPush(resource, stateId, service, push); + }, + ); + this.updateService = new UpdateService(store, this.webSocketClient); + this.contentAssistService = new ContentAssistService(this.updateService); + this.validationService = new ValidationService(store, 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); + } + + private async onPush(resource: string, stateId: string, service: string, 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:', resource); + await this.updateService.updateFullText(); + } + switch (service) { + case 'validate': + this.validationService.onPush(push); + return; + case 'highlight': + // TODO + return; + default: + log.error('Unknown push service:', service); + break; + } + } + + contentAssist(context: CompletionContext): Promise { + return this.contentAssistService.contentAssist(context); + } +} diff --git a/language-web/src/main/js/xtext/XtextWebSocketClient.ts b/language-web/src/main/js/xtext/XtextWebSocketClient.ts new file mode 100644 index 00000000..5b775500 --- /dev/null +++ b/language-web/src/main/js/xtext/XtextWebSocketClient.ts @@ -0,0 +1,345 @@ +import { nanoid } from 'nanoid'; + +import { getLogger } from '../logging'; +import { PendingTask } from '../utils/PendingTask'; +import { Timer } from '../utils/Timer'; +import { + isErrorResponse, + isOkResponse, + isPushMessage, + IXtextWebRequest, +} from './xtextMessages'; +import { isPongResult } 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('XtextWebSocketClient'); + +type ReconnectHandler = () => Promise; + +type PushHandler = ( + resourceId: string, + stateId: string, + service: string, + data: unknown, +) => Promise; + +enum State { + Initial, + Opening, + TabVisible, + TabHiddenIdle, + TabHiddenWaiting, + Error, + TimedOut, +} + +export class XtextWebSocketClient { + nextMessageId = 0; + + connection!: WebSocket; + + pendingRequests = new Map>(); + + onReconnect: ReconnectHandler; + + onPush: PushHandler; + + state = State.Initial; + + reconnectTryCount = 0; + + idleTimer = new Timer(() => { + this.handleIdleTimeout(); + }, BACKGROUND_IDLE_TIMEOUT_MS); + + pingTimer = new Timer(() => { + this.sendPing(); + }, PING_TIMEOUT_MS); + + 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().catch((error) => { + log.error('Unexpected error in onReconnect handler', error); + }); + }); + 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) => { + if (isPongResult(result) && result.pong === ping) { + log.trace('Pong', ping); + this.pingTimer.schedule(); + } else { + log.error('Invalid pong'); + 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 IXtextWebRequest); + 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; + } + if (isOkResponse(message)) { + this.resolveRequest(message.id, message.response); + } else if (isErrorResponse(message)) { + this.rejectRequest(message.id, new Error(`${message.error} error: ${message.message}`)); + if (message.error === 'server') { + log.error('Reconnecting due to server error: ', message.message); + this.forceReconnectOnError(); + } + } else if (isPushMessage(message)) { + this.onPush( + message.resource, + message.stateId, + message.service, + message.push, + ).catch((error) => { + log.error('Unexpected error in onPush handler', error); + }); + } else { + log.error('Unexpected websocket message', message); + 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/language-web/src/main/js/xtext/compatibility.js b/language-web/src/main/js/xtext/compatibility.js deleted file mode 100644 index c877fc56..00000000 --- a/language-web/src/main/js/xtext/compatibility.js +++ /dev/null @@ -1,63 +0,0 @@ -/******************************************************************************* - * Copyright (c) 2015 itemis AG (http://www.itemis.eu) and others. - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0. - * - * SPDX-License-Identifier: EPL-2.0 - *******************************************************************************/ - -define([], function() { - - if (!Function.prototype.bind) { - Function.prototype.bind = function(target) { - if (typeof this !== 'function') - throw new TypeError('bind target is not callable'); - var args = Array.prototype.slice.call(arguments, 1); - var unboundFunc = this; - var nopFunc = function() {}; - boundFunc = function() { - var localArgs = Array.prototype.slice.call(arguments); - return unboundFunc.apply(this instanceof nopFunc ? this : target, - args.concat(localArgs)); - }; - nopFunc.prototype = this.prototype; - boundFunc.prototype = new nopFunc(); - return boundFunc; - } - } - - if (!Array.prototype.map) { - Array.prototype.map = function(callback, thisArg) { - if (this == null) - throw new TypeError('this is null'); - if (typeof callback !== 'function') - throw new TypeError('callback is not callable'); - var srcArray = Object(this); - var len = srcArray.length >>> 0; - var tgtArray = new Array(len); - for (var i = 0; i < len; i++) { - if (i in srcArray) - tgtArray[i] = callback.call(thisArg, srcArray[i], i, srcArray); - } - return tgtArray; - } - } - - if (!Array.prototype.forEach) { - Array.prototype.forEach = function(callback, thisArg) { - if (this == null) - throw new TypeError('this is null'); - if (typeof callback !== 'function') - throw new TypeError('callback is not callable'); - var srcArray = Object(this); - var len = srcArray.length >>> 0; - for (var i = 0; i < len; i++) { - if (i in srcArray) - callback.call(thisArg, srcArray[i], i, srcArray); - } - } - } - - return {}; -}); diff --git a/language-web/src/main/js/xtext/services/ContentAssistService.js b/language-web/src/main/js/xtext/services/ContentAssistService.js deleted file mode 100644 index 1686570d..00000000 --- a/language-web/src/main/js/xtext/services/ContentAssistService.js +++ /dev/null @@ -1,132 +0,0 @@ -/******************************************************************************* - * Copyright (c) 2015 itemis AG (http://www.itemis.eu) and others. - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0. - * - * SPDX-License-Identifier: EPL-2.0 - *******************************************************************************/ - -define(['xtext/services/XtextService', 'jquery'], function(XtextService, jQuery) { - - /** - * Service class for content assist proposals. The proposals are returned as promise of - * a Deferred object. - */ - function ContentAssistService(serviceUrl, resourceId, updateService) { - this.initialize(serviceUrl, 'assist', resourceId, updateService); - } - - ContentAssistService.prototype = new XtextService(); - - ContentAssistService.prototype.invoke = function(editorContext, params, deferred) { - if (deferred === undefined) { - deferred = jQuery.Deferred(); - } - var serverData = { - contentType: params.contentType - }; - if (params.offset) - serverData.caretOffset = params.offset; - else - serverData.caretOffset = editorContext.getCaretOffset(); - var selection = params.selection ? params.selection : editorContext.getSelection(); - if (selection.start != serverData.caretOffset || selection.end != serverData.caretOffset) { - serverData.selectionStart = selection.start; - serverData.selectionEnd = selection.end; - } - var currentText; - var httpMethod = 'GET'; - var onComplete = undefined; - var knownServerState = editorContext.getServerState(); - if (params.sendFullText) { - serverData.fullText = editorContext.getText(); - httpMethod = 'POST'; - } else { - serverData.requiredStateId = knownServerState.stateId; - if (this._updateService) { - if (knownServerState.text === undefined || knownServerState.updateInProgress) { - var self = this; - this._updateService.addCompletionCallback(function() { - self.invoke(editorContext, params, deferred); - }); - return deferred.promise(); - } - knownServerState.updateInProgress = true; - onComplete = this._updateService.onComplete.bind(this._updateService); - currentText = editorContext.getText(); - this._updateService.computeDelta(knownServerState.text, currentText, serverData); - if (serverData.deltaText !== undefined) { - httpMethod = 'POST'; - } - } - } - - var self = this; - self.sendRequest(editorContext, { - type: httpMethod, - data: serverData, - - success: function(result) { - if (result.conflict) { - // The server has lost its session state and the resource is loaded from the server - if (self._increaseRecursionCount(editorContext)) { - if (onComplete) { - delete knownServerState.updateInProgress; - delete knownServerState.text; - delete knownServerState.stateId; - self._updateService.addCompletionCallback(function() { - self.invoke(editorContext, params, deferred); - }); - self._updateService.invoke(editorContext, params); - } else { - var paramsCopy = {}; - for (var p in params) { - if (params.hasOwnProperty(p)) - paramsCopy[p] = params[p]; - } - paramsCopy.sendFullText = true; - self.invoke(editorContext, paramsCopy, deferred); - } - } else { - deferred.reject(result.conflict); - } - return false; - } - if (onComplete && result.stateId !== undefined && result.stateId != editorContext.getServerState().stateId) { - var listeners = editorContext.updateServerState(currentText, result.stateId); - for (var i = 0; i < listeners.length; i++) { - self._updateService.addCompletionCallback(listeners[i], params); - } - } - deferred.resolve(result.entries); - }, - - error: function(xhr, textStatus, errorThrown) { - if (onComplete && xhr.status == 404 && !params.loadFromServer && knownServerState.text !== undefined) { - // The server has lost its session state and the resource is not loaded from the server - delete knownServerState.updateInProgress; - delete knownServerState.text; - delete knownServerState.stateId; - self._updateService.addCompletionCallback(function() { - self.invoke(editorContext, params, deferred); - }); - self._updateService.invoke(editorContext, params); - return true; - } - deferred.reject(errorThrown); - }, - - complete: onComplete - }, !params.sendFullText); - var result = deferred.promise(); - if (onComplete) { - result.always(function() { - knownServerState.updateInProgress = false; - }); - } - return result; - }; - - return ContentAssistService; -}); diff --git a/language-web/src/main/js/xtext/services/FormattingService.js b/language-web/src/main/js/xtext/services/FormattingService.js deleted file mode 100644 index f59099ee..00000000 --- a/language-web/src/main/js/xtext/services/FormattingService.js +++ /dev/null @@ -1,52 +0,0 @@ -/******************************************************************************* - * Copyright (c) 2015 itemis AG (http://www.itemis.eu) and others. - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0. - * - * SPDX-License-Identifier: EPL-2.0 - *******************************************************************************/ - -define(['xtext/services/XtextService', 'jquery'], function(XtextService, jQuery) { - - /** - * Service class for formatting text. - */ - function FormattingService(serviceUrl, resourceId, updateService) { - this.initialize(serviceUrl, 'format', resourceId, updateService); - }; - - FormattingService.prototype = new XtextService(); - - FormattingService.prototype._initServerData = function(serverData, editorContext, params) { - var selection = params.selection ? params.selection : editorContext.getSelection(); - if (selection.end > selection.start) { - serverData.selectionStart = selection.start; - serverData.selectionEnd = selection.end; - } - return { - httpMethod: 'POST' - }; - }; - - FormattingService.prototype._processResult = function(result, editorContext) { - // The text update may be asynchronous, so we have to compute the new text ourselves - var newText; - if (result.replaceRegion) { - var fullText = editorContext.getText(); - var start = result.replaceRegion.offset; - var end = result.replaceRegion.offset + result.replaceRegion.length; - editorContext.setText(result.formattedText, start, end); - newText = fullText.substring(0, start) + result.formattedText + fullText.substring(end); - } else { - editorContext.setText(result.formattedText); - newText = result.formattedText; - } - var listeners = editorContext.updateServerState(newText, result.stateId); - for (var i = 0; i < listeners.length; i++) { - listeners[i]({}); - } - }; - - return FormattingService; -}); \ No newline at end of file diff --git a/language-web/src/main/js/xtext/services/HighlightingService.js b/language-web/src/main/js/xtext/services/HighlightingService.js deleted file mode 100644 index 5a5ac8ba..00000000 --- a/language-web/src/main/js/xtext/services/HighlightingService.js +++ /dev/null @@ -1,33 +0,0 @@ -/******************************************************************************* - * Copyright (c) 2015 itemis AG (http://www.itemis.eu) and others. - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0. - * - * SPDX-License-Identifier: EPL-2.0 - *******************************************************************************/ - -define(['xtext/services/XtextService', 'jquery'], function(XtextService, jQuery) { - - /** - * Service class for semantic highlighting. - */ - function HighlightingService(serviceUrl, resourceId) { - this.initialize(serviceUrl, 'highlight', resourceId); - }; - - HighlightingService.prototype = new XtextService(); - - HighlightingService.prototype._checkPreconditions = function(editorContext, params) { - return this._state === undefined; - } - - HighlightingService.prototype._onConflict = function(editorContext, cause) { - this.setState(undefined); - return { - suppressForcedUpdate: true - }; - }; - - return HighlightingService; -}); \ No newline at end of file diff --git a/language-web/src/main/js/xtext/services/HoverService.js b/language-web/src/main/js/xtext/services/HoverService.js deleted file mode 100644 index 03c5a52b..00000000 --- a/language-web/src/main/js/xtext/services/HoverService.js +++ /dev/null @@ -1,59 +0,0 @@ -/******************************************************************************* - * Copyright (c) 2015 itemis AG (http://www.itemis.eu) and others. - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0. - * - * SPDX-License-Identifier: EPL-2.0 - *******************************************************************************/ - -define(['xtext/services/XtextService', 'jquery'], function(XtextService, jQuery) { - - /** - * Service class for hover information. - */ - function HoverService(serviceUrl, resourceId, updateService) { - this.initialize(serviceUrl, 'hover', resourceId, updateService); - }; - - HoverService.prototype = new XtextService(); - - HoverService.prototype._initServerData = function(serverData, editorContext, params) { - // In order to display hover info for a selected completion proposal while the content - // assist popup is shown, the selected proposal is passed as parameter - if (params.proposal && params.proposal.proposal) - serverData.proposal = params.proposal.proposal; - if (params.offset) - serverData.caretOffset = params.offset; - else - serverData.caretOffset = editorContext.getCaretOffset(); - var selection = params.selection ? params.selection : editorContext.getSelection(); - if (selection.start != serverData.caretOffset || selection.end != serverData.caretOffset) { - serverData.selectionStart = selection.start; - serverData.selectionEnd = selection.end; - } - }; - - HoverService.prototype._getSuccessCallback = function(editorContext, params, deferred) { - var delay = params.mouseHoverDelay; - if (!delay) - delay = 500; - var showTime = new Date().getTime() + delay; - return function(result) { - if (result.conflict || !result.title && !result.content) { - deferred.reject(); - } else { - var remainingTimeout = Math.max(0, showTime - new Date().getTime()); - setTimeout(function() { - if (!params.sendFullText && result.stateId !== undefined - && result.stateId != editorContext.getServerState().stateId) - deferred.reject(); - else - deferred.resolve(result); - }, remainingTimeout); - } - }; - }; - - return HoverService; -}); \ No newline at end of file diff --git a/language-web/src/main/js/xtext/services/LoadResourceService.js b/language-web/src/main/js/xtext/services/LoadResourceService.js deleted file mode 100644 index b5a315c3..00000000 --- a/language-web/src/main/js/xtext/services/LoadResourceService.js +++ /dev/null @@ -1,42 +0,0 @@ -/******************************************************************************* - * Copyright (c) 2015 itemis AG (http://www.itemis.eu) and others. - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0. - * - * SPDX-License-Identifier: EPL-2.0 - *******************************************************************************/ - -define(['xtext/services/XtextService', 'jquery'], function(XtextService, jQuery) { - - /** - * Service class for loading resources. The resulting text is passed to the editor context. - */ - function LoadResourceService(serviceUrl, resourceId, revert) { - this.initialize(serviceUrl, revert ? 'revert' : 'load', resourceId); - }; - - LoadResourceService.prototype = new XtextService(); - - LoadResourceService.prototype._initServerData = function(serverData, editorContext, params) { - return { - suppressContent: true, - httpMethod: this._serviceType == 'revert' ? 'POST' : 'GET' - }; - }; - - LoadResourceService.prototype._getSuccessCallback = function(editorContext, params, deferred) { - return function(result) { - editorContext.setText(result.fullText); - editorContext.clearUndoStack(); - editorContext.setDirty(result.dirty); - var listeners = editorContext.updateServerState(result.fullText, result.stateId); - for (var i = 0; i < listeners.length; i++) { - listeners[i](params); - } - deferred.resolve(result); - } - } - - return LoadResourceService; -}); \ No newline at end of file diff --git a/language-web/src/main/js/xtext/services/OccurrencesService.js b/language-web/src/main/js/xtext/services/OccurrencesService.js deleted file mode 100644 index 2e2d0b1a..00000000 --- a/language-web/src/main/js/xtext/services/OccurrencesService.js +++ /dev/null @@ -1,39 +0,0 @@ -/******************************************************************************* - * Copyright (c) 2015 itemis AG (http://www.itemis.eu) and others. - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0. - * - * SPDX-License-Identifier: EPL-2.0 - *******************************************************************************/ - -define(['xtext/services/XtextService', 'jquery'], function(XtextService, jQuery) { - - /** - * Service class for marking occurrences. - */ - function OccurrencesService(serviceUrl, resourceId, updateService) { - this.initialize(serviceUrl, 'occurrences', resourceId, updateService); - }; - - OccurrencesService.prototype = new XtextService(); - - OccurrencesService.prototype._initServerData = function(serverData, editorContext, params) { - if (params.offset) - serverData.caretOffset = params.offset; - else - serverData.caretOffset = editorContext.getCaretOffset(); - }; - - OccurrencesService.prototype._getSuccessCallback = function(editorContext, params, deferred) { - return function(result) { - if (result.conflict || !params.sendFullText && result.stateId !== undefined - && result.stateId != editorContext.getServerState().stateId) - deferred.reject(); - else - deferred.resolve(result); - } - } - - return OccurrencesService; -}); \ No newline at end of file diff --git a/language-web/src/main/js/xtext/services/SaveResourceService.js b/language-web/src/main/js/xtext/services/SaveResourceService.js deleted file mode 100644 index 66cdaff5..00000000 --- a/language-web/src/main/js/xtext/services/SaveResourceService.js +++ /dev/null @@ -1,32 +0,0 @@ -/******************************************************************************* - * Copyright (c) 2015 itemis AG (http://www.itemis.eu) and others. - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0. - * - * SPDX-License-Identifier: EPL-2.0 - *******************************************************************************/ - -define(['xtext/services/XtextService', 'jquery'], function(XtextService, jQuery) { - - /** - * Service class for saving resources. - */ - function SaveResourceService(serviceUrl, resourceId) { - this.initialize(serviceUrl, 'save', resourceId); - }; - - SaveResourceService.prototype = new XtextService(); - - SaveResourceService.prototype._initServerData = function(serverData, editorContext, params) { - return { - httpMethod: 'POST' - }; - }; - - SaveResourceService.prototype._processResult = function(result, editorContext) { - editorContext.setDirty(false); - }; - - return SaveResourceService; -}); \ No newline at end of file diff --git a/language-web/src/main/js/xtext/services/UpdateService.js b/language-web/src/main/js/xtext/services/UpdateService.js deleted file mode 100644 index b78d846d..00000000 --- a/language-web/src/main/js/xtext/services/UpdateService.js +++ /dev/null @@ -1,159 +0,0 @@ -/******************************************************************************* - * Copyright (c) 2015 itemis AG (http://www.itemis.eu) and others. - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0. - * - * SPDX-License-Identifier: EPL-2.0 - *******************************************************************************/ - -define(['xtext/services/XtextService', 'jquery'], function(XtextService, jQuery) { - - /** - * Service class for updating the server-side representation of a resource. - * This service only makes sense with a stateful server, where an update request is sent - * after each modification. This can greatly improve response times compared to the - * stateless alternative, where the full text content is sent with each service request. - */ - function UpdateService(serviceUrl, resourceId) { - this.initialize(serviceUrl, 'update', resourceId, this); - this._completionCallbacks = []; - }; - - UpdateService.prototype = new XtextService(); - - /** - * Compute a delta between two versions of a text. If a difference is found, the result - * contains three properties: - * deltaText - the text to insert into s1 - * deltaOffset - the text insertion offset - * deltaReplaceLength - the number of characters that shall be replaced by the inserted text - */ - UpdateService.prototype.computeDelta = function(s1, s2, result) { - var start = 0, s1length = s1.length, s2length = s2.length; - while (start < s1length && start < s2length && s1.charCodeAt(start) === s2.charCodeAt(start)) { - start++; - } - if (start === s1length && start === s2length) { - return; - } - result.deltaOffset = start; - if (start === s1length) { - result.deltaText = s2.substring(start, s2length); - result.deltaReplaceLength = 0; - return; - } else if (start === s2length) { - result.deltaText = ''; - result.deltaReplaceLength = s1length - start; - return; - } - - var end1 = s1length - 1, end2 = s2length - 1; - while (end1 >= start && end2 >= start && s1.charCodeAt(end1) === s2.charCodeAt(end2)) { - end1--; - end2--; - } - result.deltaText = s2.substring(start, end2 + 1); - result.deltaReplaceLength = end1 - start + 1; - }; - - /** - * Invoke all completion callbacks and clear the list afterwards. - */ - UpdateService.prototype.onComplete = function(xhr, textStatus) { - var callbacks = this._completionCallbacks; - this._completionCallbacks = []; - for (var i = 0; i < callbacks.length; i++) { - var callback = callbacks[i].callback; - var params = callbacks[i].params; - callback(params); - } - } - - /** - * Add a callback to be invoked when the service call has completed. - */ - UpdateService.prototype.addCompletionCallback = function(callback, params) { - this._completionCallbacks.push({callback: callback, params: params}); - } - - UpdateService.prototype.invoke = function(editorContext, params, deferred) { - if (deferred === undefined) { - deferred = jQuery.Deferred(); - } - var knownServerState = editorContext.getServerState(); - if (knownServerState.updateInProgress) { - var self = this; - this.addCompletionCallback(function() { self.invoke(editorContext, params, deferred) }); - return deferred.promise(); - } - - var serverData = { - contentType: params.contentType - }; - var currentText = editorContext.getText(); - if (params.sendFullText || knownServerState.text === undefined) { - serverData.fullText = currentText; - } else { - this.computeDelta(knownServerState.text, currentText, serverData); - if (serverData.deltaText === undefined) { - if (params.forceUpdate) { - serverData.deltaText = ''; - serverData.deltaOffset = editorContext.getCaretOffset(); - serverData.deltaReplaceLength = 0; - } else { - deferred.resolve(knownServerState); - this.onComplete(); - return deferred.promise(); - } - } - serverData.requiredStateId = knownServerState.stateId; - } - - knownServerState.updateInProgress = true; - var self = this; - self.sendRequest(editorContext, { - type: 'PUT', - data: serverData, - - success: function(result) { - if (result.conflict) { - // The server has lost its session state and the resource is loaded from the server - if (knownServerState.text !== undefined) { - delete knownServerState.updateInProgress; - delete knownServerState.text; - delete knownServerState.stateId; - self.invoke(editorContext, params, deferred); - } else { - deferred.reject(result.conflict); - } - return false; - } - var listeners = editorContext.updateServerState(currentText, result.stateId); - for (var i = 0; i < listeners.length; i++) { - self.addCompletionCallback(listeners[i], params); - } - deferred.resolve(result); - }, - - error: function(xhr, textStatus, errorThrown) { - if (xhr.status == 404 && !params.loadFromServer && knownServerState.text !== undefined) { - // The server has lost its session state and the resource is not loaded from the server - delete knownServerState.updateInProgress; - delete knownServerState.text; - delete knownServerState.stateId; - self.invoke(editorContext, params, deferred); - return true; - } - deferred.reject(errorThrown); - }, - - complete: self.onComplete.bind(self) - }, true); - return deferred.promise().always(function() { - knownServerState.updateInProgress = false; - }); - }; - - return UpdateService; -}); \ No newline at end of file diff --git a/language-web/src/main/js/xtext/services/ValidationService.js b/language-web/src/main/js/xtext/services/ValidationService.js deleted file mode 100644 index 85c9953d..00000000 --- a/language-web/src/main/js/xtext/services/ValidationService.js +++ /dev/null @@ -1,33 +0,0 @@ -/******************************************************************************* - * Copyright (c) 2015 itemis AG (http://www.itemis.eu) and others. - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0. - * - * SPDX-License-Identifier: EPL-2.0 - *******************************************************************************/ - -define(['xtext/services/XtextService', 'jquery'], function(XtextService, jQuery) { - - /** - * Service class for validation. - */ - function ValidationService(serviceUrl, resourceId) { - this.initialize(serviceUrl, 'validate', resourceId); - }; - - ValidationService.prototype = new XtextService(); - - ValidationService.prototype._checkPreconditions = function(editorContext, params) { - return this._state === undefined; - } - - ValidationService.prototype._onConflict = function(editorContext, cause) { - this.setState(undefined); - return { - suppressForcedUpdate: true - }; - }; - - return ValidationService; -}); \ No newline at end of file diff --git a/language-web/src/main/js/xtext/services/XtextService.js b/language-web/src/main/js/xtext/services/XtextService.js deleted file mode 100644 index d3a4842f..00000000 --- a/language-web/src/main/js/xtext/services/XtextService.js +++ /dev/null @@ -1,280 +0,0 @@ -/******************************************************************************* - * Copyright (c) 2015, 2017 itemis AG (http://www.itemis.eu) and others. - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0. - * - * SPDX-License-Identifier: EPL-2.0 - *******************************************************************************/ - -define(['jquery'], function(jQuery) { - - var globalState = {}; - - /** - * Generic service implementation that can serve as superclass for specialized services. - */ - function XtextService() {}; - - /** - * Initialize the request metadata for this service class. Two variants: - * - initialize(serviceUrl, serviceType, resourceId, updateService) - * - initialize(xtextServices, serviceType) - */ - XtextService.prototype.initialize = function() { - this._serviceType = arguments[1]; - if (typeof(arguments[0]) === 'string') { - this._requestUrl = arguments[0] + '/' + this._serviceType; - var resourceId = arguments[2]; - if (resourceId) - this._encodedResourceId = encodeURIComponent(resourceId); - this._updateService = arguments[3]; - } else { - var xtextServices = arguments[0]; - if (xtextServices.options) { - this._requestUrl = xtextServices.options.serviceUrl + '/' + this._serviceType; - var resourceId = xtextServices.options.resourceId; - if (resourceId) - this._encodedResourceId = encodeURIComponent(resourceId); - } - this._updateService = xtextServices.updateService; - } - } - - XtextService.prototype.setState = function(state) { - this._state = state; - } - - /** - * Invoke the service with default service behavior. - */ - XtextService.prototype.invoke = function(editorContext, params, deferred, callbacks) { - if (deferred === undefined) { - deferred = jQuery.Deferred(); - } - if (jQuery.isFunction(this._checkPreconditions) && !this._checkPreconditions(editorContext, params)) { - deferred.reject(); - return deferred.promise(); - } - var serverData = { - contentType: params.contentType - }; - var initResult; - if (jQuery.isFunction(this._initServerData)) - initResult = this._initServerData(serverData, editorContext, params); - var httpMethod = 'GET'; - if (initResult && initResult.httpMethod) - httpMethod = initResult.httpMethod; - var self = this; - if (!(initResult && initResult.suppressContent)) { - if (params.sendFullText) { - serverData.fullText = editorContext.getText(); - httpMethod = 'POST'; - } else { - var knownServerState = editorContext.getServerState(); - if (knownServerState.updateInProgress) { - if (self._updateService) { - self._updateService.addCompletionCallback(function() { - self.invoke(editorContext, params, deferred); - }); - } else { - deferred.reject(); - } - return deferred.promise(); - } - if (knownServerState.stateId !== undefined) { - serverData.requiredStateId = knownServerState.stateId; - } - } - } - - var onSuccess; - if (jQuery.isFunction(this._getSuccessCallback)) { - onSuccess = this._getSuccessCallback(editorContext, params, deferred); - } else { - onSuccess = function(result) { - if (result.conflict) { - if (self._increaseRecursionCount(editorContext)) { - var onConflictResult; - if (jQuery.isFunction(self._onConflict)) { - onConflictResult = self._onConflict(editorContext, result.conflict); - } - if (!(onConflictResult && onConflictResult.suppressForcedUpdate) && !params.sendFullText - && result.conflict == 'invalidStateId' && self._updateService) { - self._updateService.addCompletionCallback(function() { - self.invoke(editorContext, params, deferred); - }); - var knownServerState = editorContext.getServerState(); - delete knownServerState.stateId; - delete knownServerState.text; - self._updateService.invoke(editorContext, params); - } else { - self.invoke(editorContext, params, deferred); - } - } else { - deferred.reject(); - } - return false; - } - if (jQuery.isFunction(self._processResult)) { - var processedResult = self._processResult(result, editorContext); - if (processedResult) { - deferred.resolve(processedResult); - return true; - } - } - deferred.resolve(result); - }; - } - - var onError = function(xhr, textStatus, errorThrown) { - if (xhr.status == 404 && !params.loadFromServer && self._increaseRecursionCount(editorContext)) { - var onConflictResult; - if (jQuery.isFunction(self._onConflict)) { - onConflictResult = self._onConflict(editorContext, errorThrown); - } - var knownServerState = editorContext.getServerState(); - if (!(onConflictResult && onConflictResult.suppressForcedUpdate) - && knownServerState.text !== undefined && self._updateService) { - self._updateService.addCompletionCallback(function() { - self.invoke(editorContext, params, deferred); - }); - delete knownServerState.stateId; - delete knownServerState.text; - self._updateService.invoke(editorContext, params); - return true; - } - } - deferred.reject(errorThrown); - } - - self.sendRequest(editorContext, { - type: httpMethod, - data: serverData, - success: onSuccess, - error: onError - }, !params.sendFullText); - return deferred.promise().always(function() { - self._recursionCount = undefined; - }); - } - - /** - * Send an HTTP request to invoke the service. - */ - XtextService.prototype.sendRequest = function(editorContext, settings, needsSession) { - var self = this; - self.setState('started'); - var corsEnabled = editorContext.xtextServices.options['enableCors']; - if(corsEnabled) { - settings.crossDomain = true; - settings.xhrFields = {withCredentials: true}; - } - var onSuccess = settings.success; - settings.success = function(result) { - var accepted = true; - if (jQuery.isFunction(onSuccess)) { - accepted = onSuccess(result); - } - if (accepted || accepted === undefined) { - self.setState('finished'); - if (editorContext.xtextServices) { - var successListeners = editorContext.xtextServices.successListeners; - if (successListeners) { - for (var i = 0; i < successListeners.length; i++) { - var listener = successListeners[i]; - if (jQuery.isFunction(listener)) { - listener(self._serviceType, result); - } - } - } - } - } - }; - - var onError = settings.error; - settings.error = function(xhr, textStatus, errorThrown) { - var resolved = false; - if (jQuery.isFunction(onError)) { - resolved = onError(xhr, textStatus, errorThrown); - } - if (!resolved) { - self.setState(undefined); - self._reportError(editorContext, textStatus, errorThrown, xhr); - } - }; - - settings.async = true; - var requestUrl = self._requestUrl; - if (!settings.data.resource && self._encodedResourceId) { - if (requestUrl.indexOf('?') >= 0) - requestUrl += '&resource=' + self._encodedResourceId; - else - requestUrl += '?resource=' + self._encodedResourceId; - } - - if (needsSession && globalState._initPending) { - // We have to wait until the initial request has finished to make sure the client has - // received a valid session id - if (!globalState._waitingRequests) - globalState._waitingRequests = []; - globalState._waitingRequests.push({requestUrl: requestUrl, settings: settings}); - } else { - if (needsSession && !globalState._initDone) { - globalState._initPending = true; - var onComplete = settings.complete; - settings.complete = function(xhr, textStatus) { - if (jQuery.isFunction(onComplete)) { - onComplete(xhr, textStatus); - } - delete globalState._initPending; - globalState._initDone = true; - if (globalState._waitingRequests) { - for (var i = 0; i < globalState._waitingRequests.length; i++) { - var request = globalState._waitingRequests[i]; - jQuery.ajax(request.requestUrl, request.settings); - } - delete globalState._waitingRequests; - } - } - } - jQuery.ajax(requestUrl, settings); - } - } - - /** - * Use this in case of a conflict before retrying the service invocation. If the number - * of retries exceeds the limit, an error is reported and the function returns false. - */ - XtextService.prototype._increaseRecursionCount = function(editorContext) { - if (this._recursionCount === undefined) - this._recursionCount = 1; - else - this._recursionCount++; - - if (this._recursionCount >= 10) { - this._reportError(editorContext, 'warning', 'Xtext service request failed after 10 attempts.', {}); - return false; - } - return true; - }, - - /** - * Report an error to the listeners. - */ - XtextService.prototype._reportError = function(editorContext, severity, message, requestData) { - if (editorContext.xtextServices) { - var errorListeners = editorContext.xtextServices.errorListeners; - if (errorListeners) { - for (var i = 0; i < errorListeners.length; i++) { - var listener = errorListeners[i]; - if (jQuery.isFunction(listener)) { - listener(this._serviceType, severity, message, requestData); - } - } - } - } - } - - return XtextService; -}); diff --git a/language-web/src/main/js/xtext/xtext-codemirror.d.ts b/language-web/src/main/js/xtext/xtext-codemirror.d.ts deleted file mode 100644 index fff850b8..00000000 --- a/language-web/src/main/js/xtext/xtext-codemirror.d.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { Editor } from 'codemirror'; - -export function createEditor(options: IXtextOptions): IXtextCodeMirrorEditor; - -export function createServices(editor: Editor, options: IXtextOptions): IXtextServices; - -export function removeServices(editor: Editor): void; - -export interface IXtextOptions { - baseUrl?: string; - contentType?: string; - dirtyElement?: string | Element; - dirtyStatusClass?: string; - document?: Document; - enableContentAssistService?: boolean; - enableCors?: boolean; - enableFormattingAction?: boolean; - enableFormattingService?: boolean; - enableGeneratorService?: boolean; - enableHighlightingService?: boolean; - enableOccurrencesService?: boolean; - enableSaveAction?: boolean; - enableValidationService?: boolean; - loadFromServer?: boolean; - mode?: string; - parent?: string | Element; - parentClass?: string; - resourceId?: string; - selectionUpdateDelay?: number; - sendFullText?: boolean; - serviceUrl?: string; - showErrorDialogs?: boolean; - syntaxDefinition?: string; - textUpdateDelay?: number; - xtextLang?: string; -} - -export interface IXtextCodeMirrorEditor extends Editor { - xtextServices: IXtextServices; -} - -export interface IXtextServices { -} diff --git a/language-web/src/main/js/xtext/xtext-codemirror.js b/language-web/src/main/js/xtext/xtext-codemirror.js deleted file mode 100644 index d246172a..00000000 --- a/language-web/src/main/js/xtext/xtext-codemirror.js +++ /dev/null @@ -1,473 +0,0 @@ -/******************************************************************************* - * Copyright (c) 2015, 2017 itemis AG (http://www.itemis.eu) and others. - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0. - * - * SPDX-License-Identifier: EPL-2.0 - *******************************************************************************/ - -/* - * Use `createEditor(options)` to create an Xtext editor. You can specify options either - * through the function parameter or through `data-editor-x` attributes, where x is an - * option name with camelCase converted to hyphen-separated. - * In addition to the options supported by CodeMirror (https://codemirror.net/doc/manual.html#config), - * the following options are available: - * - * baseUrl = "/" {String} - * The path segment where the Xtext service is found; see serviceUrl option. - * contentType {String} - * The content type included in requests to the Xtext server. - * dirtyElement {String | DOMElement} - * An element into which the dirty status class is written when the editor is marked dirty; - * it can be either a DOM element or an ID for a DOM element. - * dirtyStatusClass = 'dirty' {String} - * A CSS class name written into the dirtyElement when the editor is marked dirty. - * document {Document} - * The document; if not specified, the global document is used. - * enableContentAssistService = true {Boolean} - * Whether content assist should be enabled. - * enableCors = true {Boolean} - * Whether CORS should be enabled for service request. - * enableFormattingAction = false {Boolean} - * Whether the formatting action should be bound to the standard keystroke ctrl+shift+s / cmd+shift+f. - * enableFormattingService = true {Boolean} - * Whether text formatting should be enabled. - * enableGeneratorService = true {Boolean} - * Whether code generation should be enabled (must be triggered through JavaScript code). - * enableHighlightingService = true {Boolean} - * Whether semantic highlighting (computed on the server) should be enabled. - * enableOccurrencesService = true {Boolean} - * Whether marking occurrences should be enabled. - * enableSaveAction = false {Boolean} - * Whether the save action should be bound to the standard keystroke ctrl+s / cmd+s. - * enableValidationService = true {Boolean} - * Whether validation should be enabled. - * loadFromServer = true {Boolean} - * Whether to load the editor content from the server. - * mode {String} - * The name of the syntax highlighting mode to use; the mode has to be registered externally - * (see CodeMirror documentation). - * parent = 'xtext-editor' {String | DOMElement} - * The parent element for the view; it can be either a DOM element or an ID for a DOM element. - * parentClass = 'xtext-editor' {String} - * If the 'parent' option is not given, this option is used to find elements that match the given class name. - * resourceId {String} - * The identifier of the resource displayed in the text editor; this option is sent to the server to - * communicate required information on the respective resource. - * selectionUpdateDelay = 550 {Number} - * The number of milliseconds to wait after a selection change before Xtext services are invoked. - * sendFullText = false {Boolean} - * Whether the full text shall be sent to the server with each request; use this if you want - * the server to run in stateless mode. If the option is inactive, the server state is updated regularly. - * serviceUrl {String} - * The URL of the Xtext servlet; if no value is given, it is constructed using the baseUrl option in the form - * {location.protocol}//{location.host}{baseUrl}xtext-service - * showErrorDialogs = false {Boolean} - * Whether errors should be displayed in popup dialogs. - * syntaxDefinition {String} - * If the 'mode' option is not set, the default mode 'xtext/{xtextLang}' is used. Set this option to - * 'none' to suppress this behavior and disable syntax highlighting. - * textUpdateDelay = 500 {Number} - * The number of milliseconds to wait after a text change before Xtext services are invoked. - * xtextLang {String} - * The language name (usually the file extension configured for the language). - */ -define([ - 'jquery', - 'codemirror', - 'codemirror/addon/hint/show-hint', - 'xtext/compatibility', - 'xtext/ServiceBuilder', - 'xtext/CodeMirrorEditorContext', - 'codemirror/mode/javascript/javascript' -], function(jQuery, CodeMirror, ShowHint, compatibility, ServiceBuilder, EditorContext) { - - var exports = {}; - - /** - * Create one or more Xtext editor instances configured with the given options. - * The return value is either a CodeMirror editor or an array of CodeMirror editors. - */ - exports.createEditor = function(options) { - if (!options) - options = {}; - - var query; - if (jQuery.type(options.parent) === 'string') { - query = jQuery('#' + options.parent, options.document); - } else if (options.parent) { - query = jQuery(options.parent); - } else if (jQuery.type(options.parentClass) === 'string') { - query = jQuery('.' + options.parentClass, options.document); - } else { - query = jQuery('#xtext-editor', options.document); - if (query.length == 0) - query = jQuery('.xtext-editor', options.document); - } - - var editors = []; - query.each(function(index, parent) { - var editorOptions = ServiceBuilder.mergeParentOptions(parent, options); - if (!editorOptions.value) - editorOptions.value = jQuery(parent).text(); - var editor = CodeMirror(function(element) { - jQuery(parent).empty().append(element); - }, editorOptions); - - exports.createServices(editor, editorOptions); - editors[index] = editor; - }); - - if (editors.length == 1) - return editors[0]; - else - return editors; - } - - function CodeMirrorServiceBuilder(editor, xtextServices) { - this.editor = editor; - xtextServices.editorContext._highlightingMarkers = []; - xtextServices.editorContext._validationMarkers = []; - xtextServices.editorContext._occurrenceMarkers = []; - ServiceBuilder.call(this, xtextServices); - } - CodeMirrorServiceBuilder.prototype = new ServiceBuilder(); - - /** - * Configure Xtext services for the given editor. The editor does not have to be created - * with createEditor(options). - */ - exports.createServices = function(editor, options) { - if (options.enableValidationService || options.enableValidationService === undefined) { - editor.setOption('gutters', ['annotations-gutter']); - } - var xtextServices = { - options: options, - editorContext: new EditorContext(editor) - }; - var serviceBuilder = new CodeMirrorServiceBuilder(editor, xtextServices); - serviceBuilder.createServices(); - xtextServices.serviceBuilder = serviceBuilder; - editor.xtextServices = xtextServices; - return xtextServices; - } - - /** - * Remove all services and listeners that have been previously created with createServices(editor, options). - */ - exports.removeServices = function(editor) { - if (!editor.xtextServices) - return; - var services = editor.xtextServices; - if (services.modelChangeListener) - editor.off('changes', services.modelChangeListener); - if (services.cursorActivityListener) - editor.off('cursorActivity', services.cursorActivityListener); - if (services.saveKeyMap) - editor.removeKeyMap(services.saveKeyMap); - if (services.contentAssistKeyMap) - editor.removeKeyMap(services.contentAssistKeyMap); - if (services.formatKeyMap) - editor.removeKeyMap(services.formatKeyMap); - var editorContext = services.editorContext; - var highlightingMarkers = editorContext._highlightingMarkers; - if (highlightingMarkers) { - for (var i = 0; i < highlightingMarkers.length; i++) { - highlightingMarkers[i].clear(); - } - } - if (editorContext._validationAnnotations) - services.serviceBuilder._clearAnnotations(editorContext._validationAnnotations); - var validationMarkers = editorContext._validationMarkers; - if (validationMarkers) { - for (var i = 0; i < validationMarkers.length; i++) { - validationMarkers[i].clear(); - } - } - var occurrenceMarkers = editorContext._occurrenceMarkers; - if (occurrenceMarkers) { - for (var i = 0; i < occurrenceMarkers.length; i++)  { - occurrenceMarkers[i].clear(); - } - } - delete editor.xtextServices; - } - - /** - * Syntax highlighting (without semantic highlighting). - */ - CodeMirrorServiceBuilder.prototype.setupSyntaxHighlighting = function() { - var options = this.services.options; - // If the mode option is set, syntax highlighting has already been configured by CM - if (!options.mode && options.syntaxDefinition != 'none' && options.xtextLang) { - this.editor.setOption('mode', 'xtext/' + options.xtextLang); - } - } - - /** - * Document update service. - */ - CodeMirrorServiceBuilder.prototype.setupUpdateService = function(refreshDocument) { - var services = this.services; - var editorContext = services.editorContext; - var textUpdateDelay = services.options.textUpdateDelay; - if (!textUpdateDelay) - textUpdateDelay = 500; - services.modelChangeListener = function(event) { - if (!event._xtext_init) - editorContext.setDirty(true); - if (editorContext._modelChangeTimeout) - clearTimeout(editorContext._modelChangeTimeout); - editorContext._modelChangeTimeout = setTimeout(function() { - if (services.options.sendFullText) - refreshDocument(); - else - services.update(); - }, textUpdateDelay); - } - if (!services.options.resourceId || !services.options.loadFromServer) - services.modelChangeListener({_xtext_init: true}); - this.editor.on('changes', services.modelChangeListener); - } - - /** - * Persistence services: load, save, and revert. - */ - CodeMirrorServiceBuilder.prototype.setupPersistenceServices = function() { - var services = this.services; - if (services.options.enableSaveAction) { - var userAgent = navigator.userAgent.toLowerCase(); - var saveFunction = function(editor) { - services.saveResource(); - }; - services.saveKeyMap = /mac os/.test(userAgent) ? {'Cmd-S': saveFunction}: {'Ctrl-S': saveFunction}; - this.editor.addKeyMap(services.saveKeyMap); - } - } - - /** - * Content assist service. - */ - CodeMirrorServiceBuilder.prototype.setupContentAssistService = function() { - var services = this.services; - var editorContext = services.editorContext; - services.contentAssistKeyMap = {'Ctrl-Space': function(editor) { - var params = ServiceBuilder.copy(services.options); - var cursor = editor.getCursor(); - params.offset = editor.indexFromPos(cursor); - services.contentAssistService.invoke(editorContext, params).done(function(entries) { - editor.showHint({hint: function(editor, options) { - return { - list: entries.map(function(entry) { - var displayText; - if (entry.label) - displayText = entry.label; - else - displayText = entry.proposal; - if (entry.description) - displayText += ' (' + entry.description + ')'; - var prefixLength = 0 - if (entry.prefix) - prefixLength = entry.prefix.length - return { - text: entry.proposal, - displayText: displayText, - from: { - line: cursor.line, - ch: cursor.ch - prefixLength - } - }; - }), - from: cursor, - to: cursor - }; - }}); - }); - }}; - this.editor.addKeyMap(services.contentAssistKeyMap); - } - - /** - * Semantic highlighting service. - */ - CodeMirrorServiceBuilder.prototype.doHighlighting = function() { - var services = this.services; - var editorContext = services.editorContext; - var editor = this.editor; - services.computeHighlighting().always(function() { - var highlightingMarkers = editorContext._highlightingMarkers; - if (highlightingMarkers) { - for (var i = 0; i < highlightingMarkers.length; i++) { - highlightingMarkers[i].clear(); - } - } - editorContext._highlightingMarkers = []; - }).done(function(result) { - for (var i = 0; i < result.regions.length; ++i) { - var region = result.regions[i]; - var from = editor.posFromIndex(region.offset); - var to = editor.posFromIndex(region.offset + region.length); - region.styleClasses.forEach(function(styleClass) { - var marker = editor.markText(from, to, {className: styleClass}); - editorContext._highlightingMarkers.push(marker); - }); - } - }); - } - - var annotationWeight = { - error: 30, - warning: 20, - info: 10 - }; - CodeMirrorServiceBuilder.prototype._getAnnotationWeight = function(annotation) { - if (annotationWeight[annotation] !== undefined) - return annotationWeight[annotation]; - else - return 0; - } - - CodeMirrorServiceBuilder.prototype._clearAnnotations = function(annotations) { - var editor = this.editor; - editor.clearGutter('annotations-gutter'); - for (var i = 0; i < annotations.length; i++) { - var annotation = annotations[i]; - if (annotation) { - annotations[i] = undefined; - } - } - } - - CodeMirrorServiceBuilder.prototype._refreshAnnotations = function(annotations) { - var editor = this.editor; - for (var i = 0; i < annotations.length; i++) { - var annotation = annotations[i]; - if (annotation) { - var classProp = ' class="xtext-annotation_' + annotation.type + '"'; - var titleProp = annotation.description ? ' title="' + annotation.description.replace(/"/g, '"') + '"' : ''; - var element = jQuery('').get(0); - editor.setGutterMarker(i, 'annotations-gutter', element); - } - } - } - - /** - * Validation service. - */ - CodeMirrorServiceBuilder.prototype.doValidation = function() { - var services = this.services; - var editorContext = services.editorContext; - var editor = this.editor; - var self = this; - services.validate().always(function() { - if (editorContext._validationAnnotations) - self._clearAnnotations(editorContext._validationAnnotations); - else - editorContext._validationAnnotations = []; - var validationMarkers = editorContext._validationMarkers; - if (validationMarkers) { - for (var i = 0; i < validationMarkers.length; i++) { - validationMarkers[i].clear(); - } - } - editorContext._validationMarkers = []; - }).done(function(result) { - var validationAnnotations = editorContext._validationAnnotations; - for (var i = 0; i < result.issues.length; i++) { - var entry = result.issues[i]; - var annotation = validationAnnotations[entry.line - 1]; - var weight = self._getAnnotationWeight(entry.severity); - if (annotation) { - if (annotation.weight < weight) { - annotation.type = entry.severity; - annotation.weight = weight; - } - if (annotation.description) - annotation.description += '\n' + entry.description; - else - annotation.description = entry.description; - } else { - validationAnnotations[entry.line - 1] = { - type: entry.severity, - weight: weight, - description: entry.description - }; - } - var from = editor.posFromIndex(entry.offset); - var to = editor.posFromIndex(entry.offset + entry.length); - var marker = editor.markText(from, to, { - className: 'xtext-marker_' + entry.severity, - title: entry.description - }); - editorContext._validationMarkers.push(marker); - } - self._refreshAnnotations(validationAnnotations); - }); - } - - /** - * Occurrences service. - */ - CodeMirrorServiceBuilder.prototype.setupOccurrencesService = function() { - var services = this.services; - var editorContext = services.editorContext; - var selectionUpdateDelay = services.options.selectionUpdateDelay; - if (!selectionUpdateDelay) - selectionUpdateDelay = 550; - var editor = this.editor; - var self = this; - services.cursorActivityListener = function() { - if (editorContext._selectionChangeTimeout) { - clearTimeout(editorContext._selectionChangeTimeout); - } - editorContext._selectionChangeTimeout = setTimeout(function() { - var params = ServiceBuilder.copy(services.options); - var cursor = editor.getCursor(); - params.offset = editor.indexFromPos(cursor); - services.occurrencesService.invoke(editorContext, params).always(function() { - var occurrenceMarkers = editorContext._occurrenceMarkers; - if (occurrenceMarkers) { - for (var i = 0; i < occurrenceMarkers.length; i++)  { - occurrenceMarkers[i].clear(); - } - } - editorContext._occurrenceMarkers = []; - }).done(function(occurrencesResult) { - for (var i = 0; i < occurrencesResult.readRegions.length; i++) { - var region = occurrencesResult.readRegions[i]; - var from = editor.posFromIndex(region.offset); - var to = editor.posFromIndex(region.offset + region.length); - var marker = editor.markText(from, to, {className: 'xtext-marker_read'}); - editorContext._occurrenceMarkers.push(marker); - } - for (var i = 0; i < occurrencesResult.writeRegions.length; i++) { - var region = occurrencesResult.writeRegions[i]; - var from = editor.posFromIndex(region.offset); - var to = editor.posFromIndex(region.offset + region.length); - var marker = editor.markText(from, to, {className: 'xtext-marker_write'}); - editorContext._occurrenceMarkers.push(marker); - } - }); - }, selectionUpdateDelay); - } - editor.on('cursorActivity', services.cursorActivityListener); - } - - /** - * Formatting service. - */ - CodeMirrorServiceBuilder.prototype.setupFormattingService = function() { - var services = this.services; - if (services.options.enableFormattingAction) { - var userAgent = navigator.userAgent.toLowerCase(); - var formatFunction = function(editor) { - services.format(); - }; - services.formatKeyMap = /mac os/.test(userAgent) ? {'Shift-Cmd-F': formatFunction}: {'Shift-Ctrl-S': formatFunction}; - this.editor.addKeyMap(services.formatKeyMap); - } - } - - return exports; -}); diff --git a/language-web/src/main/js/xtext/xtextMessages.ts b/language-web/src/main/js/xtext/xtextMessages.ts new file mode 100644 index 00000000..68737958 --- /dev/null +++ b/language-web/src/main/js/xtext/xtextMessages.ts @@ -0,0 +1,62 @@ +export interface IXtextWebRequest { + id: string; + + request: unknown; +} + +export interface IXtextWebOkResponse { + id: string; + + response: unknown; +} + +export function isOkResponse(response: unknown): response is IXtextWebOkResponse { + const okResponse = response as IXtextWebOkResponse; + return typeof okResponse === 'object' + && typeof okResponse.id === 'string' + && typeof okResponse.response !== 'undefined'; +} + +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; + + error: XtextWebErrorKind; + + message: string; +} + +export function isErrorResponse(response: unknown): response is IXtextWebErrorResponse { + const errorResponse = response as IXtextWebErrorResponse; + return typeof errorResponse === 'object' + && typeof errorResponse.id === 'string' + && isXtextWebErrorKind(errorResponse.error) + && typeof errorResponse.message === 'string'; +} + +export interface IXtextWebPushMessage { + resource: string; + + stateId: string; + + service: string; + + push: unknown; +} + +export function isPushMessage(response: unknown): response is IXtextWebPushMessage { + const pushMessage = response as IXtextWebPushMessage; + return typeof pushMessage === 'object' + && typeof pushMessage.resource === 'string' + && typeof pushMessage.stateId === 'string' + && typeof pushMessage.service === 'string' + && typeof pushMessage.push !== 'undefined'; +} diff --git a/language-web/src/main/js/xtext/xtextServiceResults.ts b/language-web/src/main/js/xtext/xtextServiceResults.ts new file mode 100644 index 00000000..6c3d9daf --- /dev/null +++ b/language-web/src/main/js/xtext/xtextServiceResults.ts @@ -0,0 +1,200 @@ +export interface IPongResult { + pong: string; +} + +export function isPongResult(result: unknown): result is IPongResult { + const pongResult = result as IPongResult; + return typeof pongResult === 'object' + && typeof pongResult.pong === 'string'; +} + +export interface IDocumentStateResult { + stateId: string; +} + +export function isDocumentStateResult(result: unknown): result is IDocumentStateResult { + const documentStateResult = result as IDocumentStateResult; + return typeof documentStateResult === 'object' + && typeof documentStateResult.stateId === 'string'; +} + +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 === '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; + + severity: Severity; + + line: number; + + column: number; + + offset: number; + + length: number; +} + +export function isIssue(value: unknown): value is IIssue { + const issue = value as IIssue; + return typeof issue === 'object' + && typeof issue.description === 'string' + && isSeverity(issue.severity) + && typeof issue.line === 'number' + && typeof issue.column === 'number' + && typeof issue.offset === 'number' + && typeof issue.length === 'number'; +} + +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' + && 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-54-g00ecf