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.ts307
1 files changed, 248 insertions, 59 deletions
diff --git a/language-web/src/main/js/editor/XtextClient.ts b/language-web/src/main/js/editor/XtextClient.ts
index 39458e93..6f789fb7 100644
--- a/language-web/src/main/js/editor/XtextClient.ts
+++ b/language-web/src/main/js/editor/XtextClient.ts
@@ -1,3 +1,8 @@
1import {
2 Completion,
3 CompletionContext,
4 CompletionResult,
5} from '@codemirror/autocomplete';
1import type { Diagnostic } from '@codemirror/lint'; 6import type { Diagnostic } from '@codemirror/lint';
2import { 7import {
3 ChangeDesc, 8 ChangeDesc,
@@ -10,21 +15,20 @@ import type { EditorStore } from './EditorStore';
10import { getLogger } from '../logging'; 15import { getLogger } from '../logging';
11import { Timer } from '../utils/Timer'; 16import { Timer } from '../utils/Timer';
12import { 17import {
18 IContentAssistEntry,
19 isContentAssistResult,
13 isDocumentStateResult, 20 isDocumentStateResult,
14 isServiceConflictResult, 21 isInvalidStateIdConflictResult,
15 isValidationResult, 22 isValidationResult,
16} from './xtextServiceResults'; 23} from './xtextServiceResults';
17import { XtextWebSocketClient } from './XtextWebSocketClient'; 24import { XtextWebSocketClient } from './XtextWebSocketClient';
25import { PendingTask } from '../utils/PendingTask';
18 26
19const UPDATE_TIMEOUT_MS = 300; 27const UPDATE_TIMEOUT_MS = 500;
20
21const log = getLogger('XtextClient');
22 28
23enum UpdateAction { 29const WAIT_FOR_UPDATE_TIMEOUT_MS = 1000;
24 ForceReconnect,
25 30
26 FullTextUpdate, 31const log = getLogger('XtextClient');
27}
28 32
29export class XtextClient { 33export class XtextClient {
30 resourceName: string; 34 resourceName: string;
@@ -37,6 +41,10 @@ export class XtextClient {
37 41
38 dirtyChanges: ChangeDesc; 42 dirtyChanges: ChangeDesc;
39 43
44 lastCompletion: CompletionResult | null = null;
45
46 updateListeners: PendingTask<void>[] = [];
47
40 updateTimer = new Timer(() => { 48 updateTimer = new Timer(() => {
41 this.handleUpdate(); 49 this.handleUpdate();
42 }, UPDATE_TIMEOUT_MS); 50 }, UPDATE_TIMEOUT_MS);
@@ -50,6 +58,7 @@ export class XtextClient {
50 this.dirtyChanges = this.newEmptyChangeDesc(); 58 this.dirtyChanges = this.newEmptyChangeDesc();
51 this.webSocketClient = new XtextWebSocketClient( 59 this.webSocketClient = new XtextWebSocketClient(
52 async () => { 60 async () => {
61 this.xtextStateId = null;
53 await this.updateFullText(); 62 await this.updateFullText();
54 }, 63 },
55 async (resource, stateId, service, push) => { 64 async (resource, stateId, service, push) => {
@@ -61,6 +70,10 @@ export class XtextClient {
61 onTransaction(transaction: Transaction): void { 70 onTransaction(transaction: Transaction): void {
62 const { changes } = transaction; 71 const { changes } = transaction;
63 if (!changes.empty) { 72 if (!changes.empty) {
73 if (this.shouldInvalidateCachedCompletion(transaction)) {
74 log.trace('Invalidating cached completions');
75 this.lastCompletion = null;
76 }
64 this.dirtyChanges = this.dirtyChanges.composeDesc(changes.desc); 77 this.dirtyChanges = this.dirtyChanges.composeDesc(changes.desc);
65 this.updateTimer.reschedule(); 78 this.updateTimer.reschedule();
66 } 79 }
@@ -110,18 +123,15 @@ export class XtextClient {
110 } 123 }
111 124
112 private computeChangesSinceLastUpdate() { 125 private computeChangesSinceLastUpdate() {
113 if (this.pendingUpdate === null) { 126 return this.pendingUpdate?.composeDesc(this.dirtyChanges) || this.dirtyChanges;
114 return this.dirtyChanges;
115 }
116 return this.pendingUpdate.composeDesc(this.dirtyChanges);
117 } 127 }
118 128
119 private handleUpdate() { 129 private handleUpdate() {
120 if (!this.webSocketClient.isOpen || this.dirtyChanges.empty) { 130 if (!this.webSocketClient.isOpen || this.dirtyChanges.empty) {
121 return; 131 return;
122 } 132 }
123 if (!this.pendingUpdate) { 133 if (this.pendingUpdate === null) {
124 this.updateDeltaText().catch((error) => { 134 this.update().catch((error) => {
125 log.error('Unexpected error during scheduled update', error); 135 log.error('Unexpected error during scheduled update', error);
126 }); 136 });
127 } 137 }
@@ -134,56 +144,201 @@ export class XtextClient {
134 } 144 }
135 145
136 private async updateFullText() { 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);
137 await this.withUpdate(async () => { 170 await this.withUpdate(async () => {
138 const result = await this.webSocketClient.send({ 171 const result = await this.webSocketClient.send({
139 resource: this.resourceName, 172 resource: this.resourceName,
140 serviceType: 'update', 173 serviceType: 'update',
141 fullText: this.store.state.doc.sliceString(0), 174 requiredStateId: this.xtextStateId,
175 ...delta,
142 }); 176 });
143 if (isDocumentStateResult(result)) { 177 if (isDocumentStateResult(result)) {
144 return result.stateId; 178 return [result.stateId, undefined];
145 } 179 }
146 if (isServiceConflictResult(result)) { 180 if (isInvalidStateIdConflictResult(result)) {
147 log.error('Full text update conflict:', result.conflict); 181 return this.doFallbackToUpdateFullText();
148 if (result.conflict === 'canceled') {
149 return UpdateAction.FullTextUpdate;
150 }
151 return UpdateAction.ForceReconnect;
152 } 182 }
153 log.error('Unexpected full text update result:', result); 183 log.error('Unexpected delta text update result:', result);
154 return UpdateAction.ForceReconnect; 184 throw new Error('Delta text update failed');
155 }); 185 });
156 } 186 }
157 187
158 private async updateDeltaText() { 188 private doFallbackToUpdateFullText() {
159 if (this.xtextStateId === null) { 189 if (this.pendingUpdate === null) {
160 await this.updateFullText(); 190 throw new Error('Only a pending update can be extended');
161 return; 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;
162 } 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();
163 const delta = this.computeDelta(); 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 }
164 log.trace('Editor delta', delta); 287 log.trace('Editor delta', delta);
165 await this.withUpdate(async () => { 288 return await this.withUpdate(async () => {
166 const result = await this.webSocketClient.send({ 289 const result = await this.webSocketClient.send({
167 resource: this.resourceName,
168 serviceType: 'update',
169 requiredStateId: this.xtextStateId, 290 requiredStateId: this.xtextStateId,
291 ...this.computeContentAssistParams(context),
170 ...delta, 292 ...delta,
171 }); 293 });
172 if (isDocumentStateResult(result)) { 294 if (isContentAssistResult(result)) {
173 return result.stateId; 295 return [result.stateId, result.entries];
174 } 296 }
175 if (isServiceConflictResult(result)) { 297 if (isInvalidStateIdConflictResult(result)) {
176 log.error('Delta text update conflict:', result.conflict); 298 const [newStateId] = await this.doFallbackToUpdateFullText();
177 return UpdateAction.FullTextUpdate; 299 if (context.aborted) {
300 return [newStateId, [] as IContentAssistEntry[]];
301 }
302 const entries = await this.doFetchContentAssist(context, newStateId);
303 return [newStateId, entries];
178 } 304 }
179 log.error('Unexpected delta text update result:', result); 305 log.error('Unextpected content assist result with delta update', result);
180 return UpdateAction.ForceReconnect; 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),
181 }); 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 };
182 } 337 }
183 338
184 private computeDelta() { 339 private computeDelta() {
185 if (this.dirtyChanges.empty) { 340 if (this.dirtyChanges.empty) {
186 return {}; 341 return null;
187 } 342 }
188 let minFromA = Number.MAX_SAFE_INTEGER; 343 let minFromA = Number.MAX_SAFE_INTEGER;
189 let maxToA = 0; 344 let maxToA = 0;
@@ -202,34 +357,68 @@ export class XtextClient {
202 }; 357 };
203 } 358 }
204 359
205 private async withUpdate(callback: () => Promise<string | UpdateAction>) { 360 private async withUpdate<T>(callback: () => Promise<[string, T]>): Promise<T> {
206 if (this.pendingUpdate !== null) { 361 if (this.pendingUpdate !== null) {
207 log.error('Another update is pending, will not perform update'); 362 throw new Error('Another update is pending, will not perform update');
208 return;
209 } 363 }
210 this.pendingUpdate = this.dirtyChanges; 364 this.pendingUpdate = this.dirtyChanges;
211 this.dirtyChanges = this.newEmptyChangeDesc(); 365 this.dirtyChanges = this.newEmptyChangeDesc();
212 let newStateId: string | UpdateAction = UpdateAction.ForceReconnect; 366 let newStateId: string | null = null;
213 try { 367 try {
214 newStateId = await callback(); 368 let result: T;
215 } catch (error) { 369 [newStateId, result] = await callback();
216 log.error('Error while updating state', error); 370 this.xtextStateId = newStateId;
217 } finally { 371 this.pendingUpdate = null;
218 if (typeof newStateId === 'string') { 372 // Copy `updateListeners` so that we don't get into a race condition
219 this.xtextStateId = newStateId; 373 // if one of the listeners adds another listener.
220 this.pendingUpdate = null; 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');
221 } else { 384 } else {
222 this.dirtyChanges = this.pendingUpdate.composeDesc(this.dirtyChanges); 385 this.dirtyChanges = this.pendingUpdate.composeDesc(this.dirtyChanges);
223 this.pendingUpdate = null;
224 switch (newStateId) {
225 case UpdateAction.ForceReconnect:
226 this.webSocketClient.forceReconnectOnError();
227 break;
228 case UpdateAction.FullTextUpdate:
229 await this.updateFullText();
230 break;
231 }
232 } 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');
233 } 422 }
234 } 423 }
235} 424}