diff options
author | 2021-10-30 02:26:43 +0200 | |
---|---|---|
committer | 2021-10-31 19:26:13 +0100 | |
commit | cc6cc0336091f838d27d66267004675ee96e1a40 (patch) | |
tree | 00e634c0b7519416392cc558ca9ca3e8d7a43f66 /language-web/src/main/js/editor/XtextClient.ts | |
parent | chore(web): refactor PendingTask (diff) | |
download | refinery-cc6cc0336091f838d27d66267004675ee96e1a40.tar.gz refinery-cc6cc0336091f838d27d66267004675ee96e1a40.tar.zst refinery-cc6cc0336091f838d27d66267004675ee96e1a40.zip |
feat(web): add xtext content assist
Diffstat (limited to 'language-web/src/main/js/editor/XtextClient.ts')
-rw-r--r-- | language-web/src/main/js/editor/XtextClient.ts | 307 |
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 @@ | |||
1 | import { | ||
2 | Completion, | ||
3 | CompletionContext, | ||
4 | CompletionResult, | ||
5 | } from '@codemirror/autocomplete'; | ||
1 | import type { Diagnostic } from '@codemirror/lint'; | 6 | import type { Diagnostic } from '@codemirror/lint'; |
2 | import { | 7 | import { |
3 | ChangeDesc, | 8 | ChangeDesc, |
@@ -10,21 +15,20 @@ import type { EditorStore } from './EditorStore'; | |||
10 | import { getLogger } from '../logging'; | 15 | import { getLogger } from '../logging'; |
11 | import { Timer } from '../utils/Timer'; | 16 | import { Timer } from '../utils/Timer'; |
12 | import { | 17 | import { |
18 | IContentAssistEntry, | ||
19 | isContentAssistResult, | ||
13 | isDocumentStateResult, | 20 | isDocumentStateResult, |
14 | isServiceConflictResult, | 21 | isInvalidStateIdConflictResult, |
15 | isValidationResult, | 22 | isValidationResult, |
16 | } from './xtextServiceResults'; | 23 | } from './xtextServiceResults'; |
17 | import { XtextWebSocketClient } from './XtextWebSocketClient'; | 24 | import { XtextWebSocketClient } from './XtextWebSocketClient'; |
25 | import { PendingTask } from '../utils/PendingTask'; | ||
18 | 26 | ||
19 | const UPDATE_TIMEOUT_MS = 300; | 27 | const UPDATE_TIMEOUT_MS = 500; |
20 | |||
21 | const log = getLogger('XtextClient'); | ||
22 | 28 | ||
23 | enum UpdateAction { | 29 | const WAIT_FOR_UPDATE_TIMEOUT_MS = 1000; |
24 | ForceReconnect, | ||
25 | 30 | ||
26 | FullTextUpdate, | 31 | const log = getLogger('XtextClient'); |
27 | } | ||
28 | 32 | ||
29 | export class XtextClient { | 33 | export 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 | } |