diff options
Diffstat (limited to 'language-web/src/main/js/editor/XtextClient.ts')
-rw-r--r-- | language-web/src/main/js/editor/XtextClient.ts | 424 |
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 @@ | |||
1 | import { | ||
2 | Completion, | ||
3 | CompletionContext, | ||
4 | CompletionResult, | ||
5 | } from '@codemirror/autocomplete'; | ||
6 | import type { Diagnostic } from '@codemirror/lint'; | ||
7 | import { | ||
8 | ChangeDesc, | ||
9 | ChangeSet, | ||
10 | Transaction, | ||
11 | } from '@codemirror/state'; | ||
12 | import { nanoid } from 'nanoid'; | ||
13 | |||
14 | import type { EditorStore } from './EditorStore'; | ||
15 | import { getLogger } from '../logging'; | ||
16 | import { Timer } from '../utils/Timer'; | ||
17 | import { | ||
18 | IContentAssistEntry, | ||
19 | isContentAssistResult, | ||
20 | isDocumentStateResult, | ||
21 | isInvalidStateIdConflictResult, | ||
22 | isValidationResult, | ||
23 | } from './xtextServiceResults'; | ||
24 | import { XtextWebSocketClient } from './XtextWebSocketClient'; | ||
25 | import { PendingTask } from '../utils/PendingTask'; | ||
26 | |||
27 | const UPDATE_TIMEOUT_MS = 500; | ||
28 | |||
29 | const WAIT_FOR_UPDATE_TIMEOUT_MS = 1000; | ||
30 | |||
31 | const log = getLogger('XtextClient'); | ||
32 | |||
33 | export 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 | } | ||