aboutsummaryrefslogtreecommitdiffstats
path: root/language-web/src/main/js/editor/XtextClient.ts
diff options
context:
space:
mode:
Diffstat (limited to 'language-web/src/main/js/editor/XtextClient.ts')
-rw-r--r--language-web/src/main/js/editor/XtextClient.ts424
1 files changed, 0 insertions, 424 deletions
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 @@
1import {
2 Completion,
3 CompletionContext,
4 CompletionResult,
5} from '@codemirror/autocomplete';
6import type { Diagnostic } from '@codemirror/lint';
7import {
8 ChangeDesc,
9 ChangeSet,
10 Transaction,
11} from '@codemirror/state';
12import { nanoid } from 'nanoid';
13
14import type { EditorStore } from './EditorStore';
15import { getLogger } from '../logging';
16import { Timer } from '../utils/Timer';
17import {
18 IContentAssistEntry,
19 isContentAssistResult,
20 isDocumentStateResult,
21 isInvalidStateIdConflictResult,
22 isValidationResult,
23} from './xtextServiceResults';
24import { XtextWebSocketClient } from './XtextWebSocketClient';
25import { PendingTask } from '../utils/PendingTask';
26
27const UPDATE_TIMEOUT_MS = 500;
28
29const WAIT_FOR_UPDATE_TIMEOUT_MS = 1000;
30
31const log = getLogger('XtextClient');
32
33export class XtextClient {
34 resourceName: string;
35
36 webSocketClient: XtextWebSocketClient;
37
38 xtextStateId: string | null = null;
39
40 pendingUpdate: ChangeDesc | null;
41
42 dirtyChanges: ChangeDesc;
43
44 lastCompletion: CompletionResult | null = null;
45
46 updateListeners: PendingTask<void>[] = [];
47
48 updateTimer = new Timer(() => {
49 this.handleUpdate();
50 }, UPDATE_TIMEOUT_MS);
51
52 store: EditorStore;
53
54 constructor(store: EditorStore) {
55 this.resourceName = `${nanoid(7)}.problem`;
56 this.pendingUpdate = null;
57 this.store = store;
58 this.dirtyChanges = this.newEmptyChangeDesc();
59 this.webSocketClient = new XtextWebSocketClient(
60 async () => {
61 this.xtextStateId = null;
62 await this.updateFullText();
63 },
64 async (resource, stateId, service, push) => {
65 await this.onPush(resource, stateId, service, push);
66 },
67 );
68 }
69
70 onTransaction(transaction: Transaction): void {
71 const { changes } = transaction;
72 if (!changes.empty) {
73 if (this.shouldInvalidateCachedCompletion(transaction)) {
74 log.trace('Invalidating cached completions');
75 this.lastCompletion = null;
76 }
77 this.dirtyChanges = this.dirtyChanges.composeDesc(changes.desc);
78 this.updateTimer.reschedule();
79 }
80 }
81
82 private async onPush(resource: string, stateId: string, service: string, push: unknown) {
83 if (resource !== this.resourceName) {
84 log.error('Unknown resource name: expected:', this.resourceName, 'got:', resource);
85 return;
86 }
87 if (stateId !== this.xtextStateId) {
88 log.error('Unexpected xtext state id: expected:', this.xtextStateId, 'got:', resource);
89 await this.updateFullText();
90 }
91 switch (service) {
92 case 'validate':
93 this.onValidate(push);
94 return;
95 case 'highlight':
96 // TODO
97 return;
98 default:
99 log.error('Unknown push service:', service);
100 break;
101 }
102 }
103
104 private onValidate(push: unknown) {
105 if (!isValidationResult(push)) {
106 log.error('Invalid validation result', push);
107 return;
108 }
109 const allChanges = this.computeChangesSinceLastUpdate();
110 const diagnostics: Diagnostic[] = [];
111 push.issues.forEach((issue) => {
112 if (issue.severity === 'ignore') {
113 return;
114 }
115 diagnostics.push({
116 from: allChanges.mapPos(issue.offset),
117 to: allChanges.mapPos(issue.offset + issue.length),
118 severity: issue.severity,
119 message: issue.description,
120 });
121 });
122 this.store.updateDiagnostics(diagnostics);
123 }
124
125 private computeChangesSinceLastUpdate() {
126 return this.pendingUpdate?.composeDesc(this.dirtyChanges) || this.dirtyChanges;
127 }
128
129 private handleUpdate() {
130 if (!this.webSocketClient.isOpen || this.dirtyChanges.empty) {
131 return;
132 }
133 if (this.pendingUpdate === null) {
134 this.update().catch((error) => {
135 log.error('Unexpected error during scheduled update', error);
136 });
137 }
138 this.updateTimer.reschedule();
139 }
140
141 private newEmptyChangeDesc() {
142 const changeSet = ChangeSet.of([], this.store.state.doc.length);
143 return changeSet.desc;
144 }
145
146 private async updateFullText() {
147 await this.withUpdate(() => this.doUpdateFullText());
148 }
149
150 private async doUpdateFullText(): Promise<[string, void]> {
151 const result = await this.webSocketClient.send({
152 resource: this.resourceName,
153 serviceType: 'update',
154 fullText: this.store.state.doc.sliceString(0),
155 });
156 if (isDocumentStateResult(result)) {
157 return [result.stateId, undefined];
158 }
159 log.error('Unexpected full text update result:', result);
160 throw new Error('Full text update failed');
161 }
162
163 async update(): Promise<void> {
164 await this.prepareForDeltaUpdate();
165 const delta = this.computeDelta();
166 if (delta === null) {
167 return;
168 }
169 log.trace('Editor delta', delta);
170 await this.withUpdate(async () => {
171 const result = await this.webSocketClient.send({
172 resource: this.resourceName,
173 serviceType: 'update',
174 requiredStateId: this.xtextStateId,
175 ...delta,
176 });
177 if (isDocumentStateResult(result)) {
178 return [result.stateId, undefined];
179 }
180 if (isInvalidStateIdConflictResult(result)) {
181 return this.doFallbackToUpdateFullText();
182 }
183 log.error('Unexpected delta text update result:', result);
184 throw new Error('Delta text update failed');
185 });
186 }
187
188 private doFallbackToUpdateFullText() {
189 if (this.pendingUpdate === null) {
190 throw new Error('Only a pending update can be extended');
191 }
192 log.warn('Delta update failed, performing full text update');
193 this.xtextStateId = null;
194 this.pendingUpdate = this.pendingUpdate.composeDesc(this.dirtyChanges);
195 this.dirtyChanges = this.newEmptyChangeDesc();
196 return this.doUpdateFullText();
197 }
198
199 async contentAssist(context: CompletionContext): Promise<CompletionResult> {
200 const tokenBefore = context.tokenBefore(['QualifiedName']);
201 if (tokenBefore === null && !context.explicit) {
202 return {
203 from: context.pos,
204 options: [],
205 };
206 }
207 const range = {
208 from: tokenBefore?.from || context.pos,
209 to: tokenBefore?.to || context.pos,
210 };
211 if (this.shouldReturnCachedCompletion(tokenBefore)) {
212 log.trace('Returning cached completion result');
213 // Postcondition of `shouldReturnCachedCompletion`: `lastCompletion !== null`
214 return {
215 ...this.lastCompletion as CompletionResult,
216 ...range,
217 };
218 }
219 const entries = await this.fetchContentAssist(context);
220 if (context.aborted) {
221 return {
222 ...range,
223 options: [],
224 };
225 }
226 const options: Completion[] = [];
227 entries.forEach((entry) => {
228 options.push({
229 label: entry.proposal,
230 detail: entry.description,
231 info: entry.documentation,
232 type: entry.kind?.toLowerCase(),
233 boost: entry.kind === 'KEYWORD' ? -90 : 0,
234 });
235 });
236 log.debug('Fetched', options.length, 'completions from server');
237 this.lastCompletion = {
238 ...range,
239 options,
240 span: /[a-zA-Z0-9_:]/,
241 };
242 return this.lastCompletion;
243 }
244
245 private shouldReturnCachedCompletion(
246 token: { from: number, to: number, text: string } | null,
247 ) {
248 if (token === null || this.lastCompletion === null) {
249 return false;
250 }
251 const { from, to, text } = token;
252 const { from: lastFrom, to: lastTo, span } = this.lastCompletion;
253 if (!lastTo) {
254 return true;
255 }
256 const transformedFrom = this.dirtyChanges.mapPos(lastFrom);
257 const transformedTo = this.dirtyChanges.mapPos(lastTo, 1);
258 return from >= transformedFrom && to <= transformedTo && span && span.exec(text);
259 }
260
261 private shouldInvalidateCachedCompletion(transaction: Transaction) {
262 if (this.lastCompletion === null) {
263 return false;
264 }
265 const { from: lastFrom, to: lastTo } = this.lastCompletion;
266 if (!lastTo) {
267 return true;
268 }
269 const transformedFrom = this.dirtyChanges.mapPos(lastFrom);
270 const transformedTo = this.dirtyChanges.mapPos(lastTo, 1);
271 let invalidate = false;
272 transaction.changes.iterChangedRanges((fromA, toA) => {
273 if (fromA < transformedFrom || toA > transformedTo) {
274 invalidate = true;
275 }
276 });
277 return invalidate;
278 }
279
280 private async fetchContentAssist(context: CompletionContext) {
281 await this.prepareForDeltaUpdate();
282 const delta = this.computeDelta();
283 if (delta === null) {
284 // Poscondition of `prepareForDeltaUpdate`: `xtextStateId !== null`
285 return this.doFetchContentAssist(context, this.xtextStateId as string);
286 }
287 log.trace('Editor delta', delta);
288 return await this.withUpdate(async () => {
289 const result = await this.webSocketClient.send({
290 requiredStateId: this.xtextStateId,
291 ...this.computeContentAssistParams(context),
292 ...delta,
293 });
294 if (isContentAssistResult(result)) {
295 return [result.stateId, result.entries];
296 }
297 if (isInvalidStateIdConflictResult(result)) {
298 const [newStateId] = await this.doFallbackToUpdateFullText();
299 if (context.aborted) {
300 return [newStateId, [] as IContentAssistEntry[]];
301 }
302 const entries = await this.doFetchContentAssist(context, newStateId);
303 return [newStateId, entries];
304 }
305 log.error('Unextpected content assist result with delta update', result);
306 throw new Error('Unexpexted content assist result with delta update');
307 });
308 }
309
310 private async doFetchContentAssist(context: CompletionContext, expectedStateId: string) {
311 const result = await this.webSocketClient.send({
312 requiredStateId: expectedStateId,
313 ...this.computeContentAssistParams(context),
314 });
315 if (isContentAssistResult(result) && result.stateId === expectedStateId) {
316 return result.entries;
317 }
318 log.error('Unexpected content assist result', result);
319 throw new Error('Unexpected content assist result');
320 }
321
322 private computeContentAssistParams(context: CompletionContext) {
323 const tokenBefore = context.tokenBefore(['QualifiedName']);
324 let selection = {};
325 if (tokenBefore !== null) {
326 selection = {
327 selectionStart: tokenBefore.from,
328 selectionEnd: tokenBefore.to,
329 };
330 }
331 return {
332 resource: this.resourceName,
333 serviceType: 'assist',
334 caretOffset: tokenBefore?.from || context.pos,
335 ...selection,
336 };
337 }
338
339 private computeDelta() {
340 if (this.dirtyChanges.empty) {
341 return null;
342 }
343 let minFromA = Number.MAX_SAFE_INTEGER;
344 let maxToA = 0;
345 let minFromB = Number.MAX_SAFE_INTEGER;
346 let maxToB = 0;
347 this.dirtyChanges.iterChangedRanges((fromA, toA, fromB, toB) => {
348 minFromA = Math.min(minFromA, fromA);
349 maxToA = Math.max(maxToA, toA);
350 minFromB = Math.min(minFromB, fromB);
351 maxToB = Math.max(maxToB, toB);
352 });
353 return {
354 deltaOffset: minFromA,
355 deltaReplaceLength: maxToA - minFromA,
356 deltaText: this.store.state.doc.sliceString(minFromB, maxToB),
357 };
358 }
359
360 private async withUpdate<T>(callback: () => Promise<[string, T]>): Promise<T> {
361 if (this.pendingUpdate !== null) {
362 throw new Error('Another update is pending, will not perform update');
363 }
364 this.pendingUpdate = this.dirtyChanges;
365 this.dirtyChanges = this.newEmptyChangeDesc();
366 let newStateId: string | null = null;
367 try {
368 let result: T;
369 [newStateId, result] = await callback();
370 this.xtextStateId = newStateId;
371 this.pendingUpdate = null;
372 // Copy `updateListeners` so that we don't get into a race condition
373 // if one of the listeners adds another listener.
374 const listeners = this.updateListeners;
375 this.updateListeners = [];
376 listeners.forEach((listener) => {
377 listener.resolve();
378 });
379 return result;
380 } catch (e) {
381 log.error('Error while update', e);
382 if (this.pendingUpdate === null) {
383 log.error('pendingUpdate was cleared during update');
384 } else {
385 this.dirtyChanges = this.pendingUpdate.composeDesc(this.dirtyChanges);
386 }
387 this.pendingUpdate = null;
388 this.webSocketClient.forceReconnectOnError();
389 const listeners = this.updateListeners;
390 this.updateListeners = [];
391 listeners.forEach((listener) => {
392 listener.reject(e);
393 });
394 throw e;
395 }
396 }
397
398 private async prepareForDeltaUpdate() {
399 if (this.pendingUpdate === null) {
400 if (this.xtextStateId === null) {
401 return;
402 }
403 await this.updateFullText();
404 }
405 let nowMs = Date.now();
406 const endMs = nowMs + WAIT_FOR_UPDATE_TIMEOUT_MS;
407 while (this.pendingUpdate !== null && nowMs < endMs) {
408 const timeoutMs = endMs - nowMs;
409 const promise = new Promise((resolve, reject) => {
410 const task = new PendingTask(resolve, reject, timeoutMs);
411 this.updateListeners.push(task);
412 });
413 // We must keep waiting uptil the update has completed,
414 // so the tasks can't be started in parallel.
415 // eslint-disable-next-line no-await-in-loop
416 await promise;
417 nowMs = Date.now();
418 }
419 if (this.pendingUpdate !== null || this.xtextStateId === null) {
420 log.error('No successful update in', WAIT_FOR_UPDATE_TIMEOUT_MS, 'ms');
421 throw new Error('Failed to wait for successful update');
422 }
423 }
424}