diff options
author | Kristóf Marussy <kristof@marussy.com> | 2021-10-30 13:48:52 +0200 |
---|---|---|
committer | Kristóf Marussy <kristof@marussy.com> | 2021-10-31 19:26:13 +0100 |
commit | cdb493b0a47bcf64e8e670b94fa399fcd731f531 (patch) | |
tree | b6b03aec77ef87a2dda7585be7884a30c65d93f5 /language-web/src/main/js/xtext/UpdateService.ts | |
parent | feat(web): add xtext content assist (diff) | |
download | refinery-cdb493b0a47bcf64e8e670b94fa399fcd731f531.tar.gz refinery-cdb493b0a47bcf64e8e670b94fa399fcd731f531.tar.zst refinery-cdb493b0a47bcf64e8e670b94fa399fcd731f531.zip |
chore(web): refactor xtext client
Diffstat (limited to 'language-web/src/main/js/xtext/UpdateService.ts')
-rw-r--r-- | language-web/src/main/js/xtext/UpdateService.ts | 271 |
1 files changed, 271 insertions, 0 deletions
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 @@ | |||
1 | import { | ||
2 | ChangeDesc, | ||
3 | ChangeSet, | ||
4 | Transaction, | ||
5 | } from '@codemirror/state'; | ||
6 | import { nanoid } from 'nanoid'; | ||
7 | |||
8 | import type { EditorStore } from '../editor/EditorStore'; | ||
9 | import { getLogger } from '../logging'; | ||
10 | import type { XtextWebSocketClient } from './XtextWebSocketClient'; | ||
11 | import { PendingTask } from '../utils/PendingTask'; | ||
12 | import { Timer } from '../utils/Timer'; | ||
13 | import { | ||
14 | IContentAssistEntry, | ||
15 | isContentAssistResult, | ||
16 | isDocumentStateResult, | ||
17 | isInvalidStateIdConflictResult, | ||
18 | } from './xtextServiceResults'; | ||
19 | |||
20 | const UPDATE_TIMEOUT_MS = 500; | ||
21 | |||
22 | const WAIT_FOR_UPDATE_TIMEOUT_MS = 1000; | ||
23 | |||
24 | const log = getLogger('xtext.UpdateService'); | ||
25 | |||
26 | export interface IAbortSignal { | ||
27 | aborted: boolean; | ||
28 | } | ||
29 | |||
30 | export class UpdateService { | ||
31 | resourceName: string; | ||
32 | |||
33 | xtextStateId: string | null = null; | ||
34 | |||
35 | private store: EditorStore; | ||
36 | |||
37 | private pendingUpdate: ChangeDesc | null = null; | ||
38 | |||
39 | private dirtyChanges: ChangeDesc; | ||
40 | |||
41 | private webSocketClient: XtextWebSocketClient; | ||
42 | |||
43 | private updateListeners: PendingTask<void>[] = []; | ||
44 | |||
45 | private idleUpdateTimer = new Timer(() => { | ||
46 | this.handleIdleUpdate(); | ||
47 | }, UPDATE_TIMEOUT_MS); | ||
48 | |||
49 | constructor(store: EditorStore, webSocketClient: XtextWebSocketClient) { | ||
50 | this.resourceName = `${nanoid(7)}.problem`; | ||
51 | this.store = store; | ||
52 | this.dirtyChanges = this.newEmptyChangeDesc(); | ||
53 | this.webSocketClient = webSocketClient; | ||
54 | } | ||
55 | |||
56 | onTransaction(transaction: Transaction): void { | ||
57 | const { changes } = transaction; | ||
58 | if (!changes.empty) { | ||
59 | this.dirtyChanges = this.dirtyChanges.composeDesc(changes.desc); | ||
60 | this.idleUpdateTimer.reschedule(); | ||
61 | } | ||
62 | } | ||
63 | |||
64 | computeChangesSinceLastUpdate(): ChangeDesc { | ||
65 | return this.pendingUpdate?.composeDesc(this.dirtyChanges) || this.dirtyChanges; | ||
66 | } | ||
67 | |||
68 | private handleIdleUpdate() { | ||
69 | if (!this.webSocketClient.isOpen || this.dirtyChanges.empty) { | ||
70 | return; | ||
71 | } | ||
72 | if (this.pendingUpdate === null) { | ||
73 | this.update().catch((error) => { | ||
74 | log.error('Unexpected error during scheduled update', error); | ||
75 | }); | ||
76 | } | ||
77 | this.idleUpdateTimer.reschedule(); | ||
78 | } | ||
79 | |||
80 | private newEmptyChangeDesc() { | ||
81 | const changeSet = ChangeSet.of([], this.store.state.doc.length); | ||
82 | return changeSet.desc; | ||
83 | } | ||
84 | |||
85 | async updateFullText(): Promise<void> { | ||
86 | await this.withUpdate(() => this.doUpdateFullText()); | ||
87 | } | ||
88 | |||
89 | private async doUpdateFullText(): Promise<[string, void]> { | ||
90 | const result = await this.webSocketClient.send({ | ||
91 | resource: this.resourceName, | ||
92 | serviceType: 'update', | ||
93 | fullText: this.store.state.doc.sliceString(0), | ||
94 | }); | ||
95 | if (isDocumentStateResult(result)) { | ||
96 | return [result.stateId, undefined]; | ||
97 | } | ||
98 | log.error('Unexpected full text update result:', result); | ||
99 | throw new Error('Full text update failed'); | ||
100 | } | ||
101 | |||
102 | async update(): Promise<void> { | ||
103 | await this.prepareForDeltaUpdate(); | ||
104 | const delta = this.computeDelta(); | ||
105 | if (delta === null) { | ||
106 | return; | ||
107 | } | ||
108 | log.trace('Editor delta', delta); | ||
109 | await this.withUpdate(async () => { | ||
110 | const result = await this.webSocketClient.send({ | ||
111 | resource: this.resourceName, | ||
112 | serviceType: 'update', | ||
113 | requiredStateId: this.xtextStateId, | ||
114 | ...delta, | ||
115 | }); | ||
116 | if (isDocumentStateResult(result)) { | ||
117 | return [result.stateId, undefined]; | ||
118 | } | ||
119 | if (isInvalidStateIdConflictResult(result)) { | ||
120 | return this.doFallbackToUpdateFullText(); | ||
121 | } | ||
122 | log.error('Unexpected delta text update result:', result); | ||
123 | throw new Error('Delta text update failed'); | ||
124 | }); | ||
125 | } | ||
126 | |||
127 | private doFallbackToUpdateFullText() { | ||
128 | if (this.pendingUpdate === null) { | ||
129 | throw new Error('Only a pending update can be extended'); | ||
130 | } | ||
131 | log.warn('Delta update failed, performing full text update'); | ||
132 | this.xtextStateId = null; | ||
133 | this.pendingUpdate = this.pendingUpdate.composeDesc(this.dirtyChanges); | ||
134 | this.dirtyChanges = this.newEmptyChangeDesc(); | ||
135 | return this.doUpdateFullText(); | ||
136 | } | ||
137 | |||
138 | async fetchContentAssist( | ||
139 | params: Record<string, unknown>, | ||
140 | signal: IAbortSignal, | ||
141 | ): Promise<IContentAssistEntry[]> { | ||
142 | await this.prepareForDeltaUpdate(); | ||
143 | if (signal.aborted) { | ||
144 | return []; | ||
145 | } | ||
146 | const delta = this.computeDelta(); | ||
147 | if (delta === null) { | ||
148 | // Poscondition of `prepareForDeltaUpdate`: `xtextStateId !== null` | ||
149 | return this.doFetchContentAssist(params, this.xtextStateId as string); | ||
150 | } | ||
151 | log.trace('Editor delta', delta); | ||
152 | return this.withUpdate(async () => { | ||
153 | const result = await this.webSocketClient.send({ | ||
154 | ...params, | ||
155 | requiredStateId: this.xtextStateId, | ||
156 | ...delta, | ||
157 | }); | ||
158 | if (isContentAssistResult(result)) { | ||
159 | return [result.stateId, result.entries]; | ||
160 | } | ||
161 | if (isInvalidStateIdConflictResult(result)) { | ||
162 | const [newStateId] = await this.doFallbackToUpdateFullText(); | ||
163 | if (signal.aborted) { | ||
164 | return [newStateId, []]; | ||
165 | } | ||
166 | const entries = await this.doFetchContentAssist(params, newStateId); | ||
167 | return [newStateId, entries]; | ||
168 | } | ||
169 | log.error('Unextpected content assist result with delta update', result); | ||
170 | throw new Error('Unexpexted content assist result with delta update'); | ||
171 | }); | ||
172 | } | ||
173 | |||
174 | private async doFetchContentAssist(params: Record<string, unknown>, expectedStateId: string) { | ||
175 | const result = await this.webSocketClient.send({ | ||
176 | ...params, | ||
177 | requiredStateId: expectedStateId, | ||
178 | }); | ||
179 | if (isContentAssistResult(result) && result.stateId === expectedStateId) { | ||
180 | return result.entries; | ||
181 | } | ||
182 | log.error('Unexpected content assist result', result); | ||
183 | throw new Error('Unexpected content assist result'); | ||
184 | } | ||
185 | |||
186 | private computeDelta() { | ||
187 | if (this.dirtyChanges.empty) { | ||
188 | return null; | ||
189 | } | ||
190 | let minFromA = Number.MAX_SAFE_INTEGER; | ||
191 | let maxToA = 0; | ||
192 | let minFromB = Number.MAX_SAFE_INTEGER; | ||
193 | let maxToB = 0; | ||
194 | this.dirtyChanges.iterChangedRanges((fromA, toA, fromB, toB) => { | ||
195 | minFromA = Math.min(minFromA, fromA); | ||
196 | maxToA = Math.max(maxToA, toA); | ||
197 | minFromB = Math.min(minFromB, fromB); | ||
198 | maxToB = Math.max(maxToB, toB); | ||
199 | }); | ||
200 | return { | ||
201 | deltaOffset: minFromA, | ||
202 | deltaReplaceLength: maxToA - minFromA, | ||
203 | deltaText: this.store.state.doc.sliceString(minFromB, maxToB), | ||
204 | }; | ||
205 | } | ||
206 | |||
207 | private async withUpdate<T>(callback: () => Promise<[string, T]>): Promise<T> { | ||
208 | if (this.pendingUpdate !== null) { | ||
209 | throw new Error('Another update is pending, will not perform update'); | ||
210 | } | ||
211 | this.pendingUpdate = this.dirtyChanges; | ||
212 | this.dirtyChanges = this.newEmptyChangeDesc(); | ||
213 | let newStateId: string | null = null; | ||
214 | try { | ||
215 | let result: T; | ||
216 | [newStateId, result] = await callback(); | ||
217 | this.xtextStateId = newStateId; | ||
218 | this.pendingUpdate = null; | ||
219 | // Copy `updateListeners` so that we don't get into a race condition | ||
220 | // if one of the listeners adds another listener. | ||
221 | const listeners = this.updateListeners; | ||
222 | this.updateListeners = []; | ||
223 | listeners.forEach((listener) => { | ||
224 | listener.resolve(); | ||
225 | }); | ||
226 | return result; | ||
227 | } catch (e) { | ||
228 | log.error('Error while update', e); | ||
229 | if (this.pendingUpdate === null) { | ||
230 | log.error('pendingUpdate was cleared during update'); | ||
231 | } else { | ||
232 | this.dirtyChanges = this.pendingUpdate.composeDesc(this.dirtyChanges); | ||
233 | } | ||
234 | this.pendingUpdate = null; | ||
235 | this.webSocketClient.forceReconnectOnError(); | ||
236 | const listeners = this.updateListeners; | ||
237 | this.updateListeners = []; | ||
238 | listeners.forEach((listener) => { | ||
239 | listener.reject(e); | ||
240 | }); | ||
241 | throw e; | ||
242 | } | ||
243 | } | ||
244 | |||
245 | private async prepareForDeltaUpdate() { | ||
246 | if (this.pendingUpdate === null) { | ||
247 | if (this.xtextStateId === null) { | ||
248 | return; | ||
249 | } | ||
250 | await this.updateFullText(); | ||
251 | } | ||
252 | let nowMs = Date.now(); | ||
253 | const endMs = nowMs + WAIT_FOR_UPDATE_TIMEOUT_MS; | ||
254 | while (this.pendingUpdate !== null && nowMs < endMs) { | ||
255 | const timeoutMs = endMs - nowMs; | ||
256 | const promise = new Promise((resolve, reject) => { | ||
257 | const task = new PendingTask(resolve, reject, timeoutMs); | ||
258 | this.updateListeners.push(task); | ||
259 | }); | ||
260 | // We must keep waiting uptil the update has completed, | ||
261 | // so the tasks can't be started in parallel. | ||
262 | // eslint-disable-next-line no-await-in-loop | ||
263 | await promise; | ||
264 | nowMs = Date.now(); | ||
265 | } | ||
266 | if (this.pendingUpdate !== null || this.xtextStateId === null) { | ||
267 | log.error('No successful update in', WAIT_FOR_UPDATE_TIMEOUT_MS, 'ms'); | ||
268 | throw new Error('Failed to wait for successful update'); | ||
269 | } | ||
270 | } | ||
271 | } | ||