aboutsummaryrefslogtreecommitdiffstats
path: root/language-web/src/main/js/xtext/UpdateService.ts
diff options
context:
space:
mode:
Diffstat (limited to 'language-web/src/main/js/xtext/UpdateService.ts')
-rw-r--r--language-web/src/main/js/xtext/UpdateService.ts271
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 @@
1import {
2 ChangeDesc,
3 ChangeSet,
4 Transaction,
5} from '@codemirror/state';
6import { nanoid } from 'nanoid';
7
8import type { EditorStore } from '../editor/EditorStore';
9import { getLogger } from '../logging';
10import type { XtextWebSocketClient } from './XtextWebSocketClient';
11import { PendingTask } from '../utils/PendingTask';
12import { Timer } from '../utils/Timer';
13import {
14 IContentAssistEntry,
15 isContentAssistResult,
16 isDocumentStateResult,
17 isInvalidStateIdConflictResult,
18} from './xtextServiceResults';
19
20const UPDATE_TIMEOUT_MS = 500;
21
22const WAIT_FOR_UPDATE_TIMEOUT_MS = 1000;
23
24const log = getLogger('xtext.UpdateService');
25
26export interface IAbortSignal {
27 aborted: boolean;
28}
29
30export 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}