diff options
author | Kristóf Marussy <kristof@marussy.com> | 2021-10-30 02:26:43 +0200 |
---|---|---|
committer | Kristóf Marussy <kristof@marussy.com> | 2021-10-31 19:26:13 +0100 |
commit | cc6cc0336091f838d27d66267004675ee96e1a40 (patch) | |
tree | 00e634c0b7519416392cc558ca9ca3e8d7a43f66 /language-web | |
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')
-rw-r--r-- | language-web/src/main/js/editor/EditorParent.ts | 12 | ||||
-rw-r--r-- | language-web/src/main/js/editor/EditorStore.ts | 7 | ||||
-rw-r--r-- | language-web/src/main/js/editor/XtextClient.ts | 307 | ||||
-rw-r--r-- | language-web/src/main/js/editor/xtextMessages.ts | 8 | ||||
-rw-r--r-- | language-web/src/main/js/editor/xtextServiceResults.ts | 138 |
5 files changed, 402 insertions, 70 deletions
diff --git a/language-web/src/main/js/editor/EditorParent.ts b/language-web/src/main/js/editor/EditorParent.ts index a2f6c266..0a25214b 100644 --- a/language-web/src/main/js/editor/EditorParent.ts +++ b/language-web/src/main/js/editor/EditorParent.ts | |||
@@ -5,13 +5,15 @@ export const EditorParent = styled('div')(({ theme }) => ({ | |||
5 | '&, .cm-editor': { | 5 | '&, .cm-editor': { |
6 | height: '100%', | 6 | height: '100%', |
7 | }, | 7 | }, |
8 | '.cm-scroller': { | 8 | '.cm-scroller, .cm-tooltip-autocomplete, .cm-completionLabel, .cm-completionDetail': { |
9 | fontSize: 16, | 9 | fontSize: 16, |
10 | fontFamily: '"JetBrains MonoVariable", "JetBrains Mono", monospace', | 10 | fontFamily: '"JetBrains MonoVariable", "JetBrains Mono", monospace', |
11 | fontFeatureSettings: '"liga", "calt"', | 11 | fontFeatureSettings: '"liga", "calt"', |
12 | fontWeight: 400, | 12 | fontWeight: 400, |
13 | letterSpacing: 0, | 13 | letterSpacing: 0, |
14 | textRendering: 'optimizeLegibility', | 14 | textRendering: 'optimizeLegibility', |
15 | }, | ||
16 | '.cm-scroller': { | ||
15 | color: theme.palette.text.secondary, | 17 | color: theme.palette.text.secondary, |
16 | }, | 18 | }, |
17 | '.cm-gutters': { | 19 | '.cm-gutters': { |
@@ -59,7 +61,7 @@ export const EditorParent = styled('div')(({ theme }) => ({ | |||
59 | color: theme.palette.text.secondary, | 61 | color: theme.palette.text.secondary, |
60 | }, | 62 | }, |
61 | '.cmt-comment': { | 63 | '.cmt-comment': { |
62 | fontVariant: 'italic', | 64 | fontStyle: 'italic', |
63 | color: theme.palette.text.disabled, | 65 | color: theme.palette.text.disabled, |
64 | }, | 66 | }, |
65 | '.cmt-number': { | 67 | '.cmt-number': { |
@@ -77,4 +79,10 @@ export const EditorParent = styled('div')(({ theme }) => ({ | |||
77 | '.cmt-variableName': { | 79 | '.cmt-variableName': { |
78 | color: '#c8ae9d', | 80 | color: '#c8ae9d', |
79 | }, | 81 | }, |
82 | '.cm-completionIcon': { | ||
83 | width: 16, | ||
84 | padding: 0, | ||
85 | marginRight: '0.5em', | ||
86 | textAlign: 'center', | ||
87 | }, | ||
80 | })); | 88 | })); |
diff --git a/language-web/src/main/js/editor/EditorStore.ts b/language-web/src/main/js/editor/EditorStore.ts index 32fe6fd1..dcc69fd1 100644 --- a/language-web/src/main/js/editor/EditorStore.ts +++ b/language-web/src/main/js/editor/EditorStore.ts | |||
@@ -79,7 +79,12 @@ export class EditorStore { | |||
79 | this.state = EditorState.create({ | 79 | this.state = EditorState.create({ |
80 | doc: initialValue, | 80 | doc: initialValue, |
81 | extensions: [ | 81 | extensions: [ |
82 | autocompletion(), | 82 | autocompletion({ |
83 | activateOnTyping: true, | ||
84 | override: [ | ||
85 | (context) => this.client.contentAssist(context), | ||
86 | ], | ||
87 | }), | ||
83 | classHighlightStyle.extension, | 88 | classHighlightStyle.extension, |
84 | closeBrackets(), | 89 | closeBrackets(), |
85 | bracketMatching(), | 90 | bracketMatching(), |
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 | } |
diff --git a/language-web/src/main/js/editor/xtextMessages.ts b/language-web/src/main/js/editor/xtextMessages.ts index be3125e6..68737958 100644 --- a/language-web/src/main/js/editor/xtextMessages.ts +++ b/language-web/src/main/js/editor/xtextMessages.ts | |||
@@ -21,6 +21,11 @@ export const VALID_XTEXT_WEB_ERROR_KINDS = ['request', 'server'] as const; | |||
21 | 21 | ||
22 | export type XtextWebErrorKind = typeof VALID_XTEXT_WEB_ERROR_KINDS[number]; | 22 | export type XtextWebErrorKind = typeof VALID_XTEXT_WEB_ERROR_KINDS[number]; |
23 | 23 | ||
24 | export function isXtextWebErrorKind(value: unknown): value is XtextWebErrorKind { | ||
25 | return typeof value === 'string' | ||
26 | && VALID_XTEXT_WEB_ERROR_KINDS.includes(value as XtextWebErrorKind); | ||
27 | } | ||
28 | |||
24 | export interface IXtextWebErrorResponse { | 29 | export interface IXtextWebErrorResponse { |
25 | id: string; | 30 | id: string; |
26 | 31 | ||
@@ -33,8 +38,7 @@ export function isErrorResponse(response: unknown): response is IXtextWebErrorRe | |||
33 | const errorResponse = response as IXtextWebErrorResponse; | 38 | const errorResponse = response as IXtextWebErrorResponse; |
34 | return typeof errorResponse === 'object' | 39 | return typeof errorResponse === 'object' |
35 | && typeof errorResponse.id === 'string' | 40 | && typeof errorResponse.id === 'string' |
36 | && typeof errorResponse.error === 'string' | 41 | && isXtextWebErrorKind(errorResponse.error) |
37 | && VALID_XTEXT_WEB_ERROR_KINDS.includes(errorResponse.error) | ||
38 | && typeof errorResponse.message === 'string'; | 42 | && typeof errorResponse.message === 'string'; |
39 | } | 43 | } |
40 | 44 | ||
diff --git a/language-web/src/main/js/editor/xtextServiceResults.ts b/language-web/src/main/js/editor/xtextServiceResults.ts index 8a4afa40..6c3d9daf 100644 --- a/language-web/src/main/js/editor/xtextServiceResults.ts +++ b/language-web/src/main/js/editor/xtextServiceResults.ts | |||
@@ -22,20 +22,32 @@ export const VALID_CONFLICTS = ['invalidStateId', 'canceled'] as const; | |||
22 | 22 | ||
23 | export type Conflict = typeof VALID_CONFLICTS[number]; | 23 | export type Conflict = typeof VALID_CONFLICTS[number]; |
24 | 24 | ||
25 | export function isConflict(value: unknown): value is Conflict { | ||
26 | return typeof value === 'string' && VALID_CONFLICTS.includes(value as Conflict); | ||
27 | } | ||
28 | |||
25 | export interface IServiceConflictResult { | 29 | export interface IServiceConflictResult { |
26 | conflict: Conflict; | 30 | conflict: Conflict; |
27 | } | 31 | } |
28 | 32 | ||
29 | export function isServiceConflictResult(result: unknown): result is IServiceConflictResult { | 33 | export function isServiceConflictResult(result: unknown): result is IServiceConflictResult { |
30 | const serviceConflictResult = result as IServiceConflictResult; | 34 | const serviceConflictResult = result as IServiceConflictResult; |
31 | return typeof serviceConflictResult.conflict === 'string' | 35 | return typeof serviceConflictResult === 'object' |
32 | && VALID_CONFLICTS.includes(serviceConflictResult.conflict); | 36 | && isConflict(serviceConflictResult.conflict); |
37 | } | ||
38 | |||
39 | export function isInvalidStateIdConflictResult(result: unknown): boolean { | ||
40 | return isServiceConflictResult(result) && result.conflict === 'invalidStateId'; | ||
33 | } | 41 | } |
34 | 42 | ||
35 | export const VALID_SEVERITIES = ['error', 'warning', 'info', 'ignore'] as const; | 43 | export const VALID_SEVERITIES = ['error', 'warning', 'info', 'ignore'] as const; |
36 | 44 | ||
37 | export type Severity = typeof VALID_SEVERITIES[number]; | 45 | export type Severity = typeof VALID_SEVERITIES[number]; |
38 | 46 | ||
47 | export function isSeverity(value: unknown): value is Severity { | ||
48 | return typeof value === 'string' && VALID_SEVERITIES.includes(value as Severity); | ||
49 | } | ||
50 | |||
39 | export interface IIssue { | 51 | export interface IIssue { |
40 | description: string; | 52 | description: string; |
41 | 53 | ||
@@ -54,8 +66,7 @@ export function isIssue(value: unknown): value is IIssue { | |||
54 | const issue = value as IIssue; | 66 | const issue = value as IIssue; |
55 | return typeof issue === 'object' | 67 | return typeof issue === 'object' |
56 | && typeof issue.description === 'string' | 68 | && typeof issue.description === 'string' |
57 | && typeof issue.severity === 'string' | 69 | && isSeverity(issue.severity) |
58 | && VALID_SEVERITIES.includes(issue.severity) | ||
59 | && typeof issue.line === 'number' | 70 | && typeof issue.line === 'number' |
60 | && typeof issue.column === 'number' | 71 | && typeof issue.column === 'number' |
61 | && typeof issue.offset === 'number' | 72 | && typeof issue.offset === 'number' |
@@ -66,9 +77,124 @@ export interface IValidationResult { | |||
66 | issues: IIssue[]; | 77 | issues: IIssue[]; |
67 | } | 78 | } |
68 | 79 | ||
80 | function isArrayOfType<T>(value: unknown, check: (entry: unknown) => entry is T): value is T[] { | ||
81 | return Array.isArray(value) && (value as T[]).every(check); | ||
82 | } | ||
83 | |||
69 | export function isValidationResult(result: unknown): result is IValidationResult { | 84 | export function isValidationResult(result: unknown): result is IValidationResult { |
70 | const validationResult = result as IValidationResult; | 85 | const validationResult = result as IValidationResult; |
71 | return typeof validationResult === 'object' | 86 | return typeof validationResult === 'object' |
72 | && Array.isArray(validationResult.issues) | 87 | && isArrayOfType(validationResult.issues, isIssue); |
73 | && validationResult.issues.every(isIssue); | 88 | } |
89 | |||
90 | export interface IReplaceRegion { | ||
91 | offset: number; | ||
92 | |||
93 | length: number; | ||
94 | |||
95 | text: string; | ||
96 | } | ||
97 | |||
98 | export function isReplaceRegion(value: unknown): value is IReplaceRegion { | ||
99 | const replaceRegion = value as IReplaceRegion; | ||
100 | return typeof replaceRegion === 'object' | ||
101 | && typeof replaceRegion.offset === 'number' | ||
102 | && typeof replaceRegion.length === 'number' | ||
103 | && typeof replaceRegion.text === 'string'; | ||
104 | } | ||
105 | |||
106 | export interface ITextRegion { | ||
107 | offset: number; | ||
108 | |||
109 | length: number; | ||
110 | } | ||
111 | |||
112 | export function isTextRegion(value: unknown): value is ITextRegion { | ||
113 | const textRegion = value as ITextRegion; | ||
114 | return typeof textRegion === 'object' | ||
115 | && typeof textRegion.offset === 'number' | ||
116 | && typeof textRegion.length === 'number'; | ||
117 | } | ||
118 | |||
119 | export const VALID_XTEXT_CONTENT_ASSIST_ENTRY_KINDS = [ | ||
120 | 'TEXT', | ||
121 | 'METHOD', | ||
122 | 'FUNCTION', | ||
123 | 'CONSTRUCTOR', | ||
124 | 'FIELD', | ||
125 | 'VARIABLE', | ||
126 | 'CLASS', | ||
127 | 'INTERFACE', | ||
128 | 'MODULE', | ||
129 | 'PROPERTY', | ||
130 | 'UNIT', | ||
131 | 'VALUE', | ||
132 | 'ENUM', | ||
133 | 'KEYWORD', | ||
134 | 'SNIPPET', | ||
135 | 'COLOR', | ||
136 | 'FILE', | ||
137 | 'REFERENCE', | ||
138 | 'UNKNOWN', | ||
139 | ] as const; | ||
140 | |||
141 | export type XtextContentAssistEntryKind = typeof VALID_XTEXT_CONTENT_ASSIST_ENTRY_KINDS[number]; | ||
142 | |||
143 | export function isXtextContentAssistEntryKind( | ||
144 | value: unknown, | ||
145 | ): value is XtextContentAssistEntryKind { | ||
146 | return typeof value === 'string' | ||
147 | && VALID_XTEXT_CONTENT_ASSIST_ENTRY_KINDS.includes(value as XtextContentAssistEntryKind); | ||
148 | } | ||
149 | |||
150 | export interface IContentAssistEntry { | ||
151 | prefix: string; | ||
152 | |||
153 | proposal: string; | ||
154 | |||
155 | label?: string; | ||
156 | |||
157 | description?: string; | ||
158 | |||
159 | documentation?: string; | ||
160 | |||
161 | escapePosition?: number; | ||
162 | |||
163 | textReplacements: IReplaceRegion[]; | ||
164 | |||
165 | editPositions: ITextRegion[]; | ||
166 | |||
167 | kind: XtextContentAssistEntryKind | string; | ||
168 | } | ||
169 | |||
170 | function isStringOrUndefined(value: unknown): value is string | undefined { | ||
171 | return typeof value === 'string' || typeof value === 'undefined'; | ||
172 | } | ||
173 | |||
174 | function isNumberOrUndefined(value: unknown): value is number | undefined { | ||
175 | return typeof value === 'number' || typeof value === 'undefined'; | ||
176 | } | ||
177 | |||
178 | export function isContentAssistEntry(value: unknown): value is IContentAssistEntry { | ||
179 | const entry = value as IContentAssistEntry; | ||
180 | return typeof entry === 'object' | ||
181 | && typeof entry.prefix === 'string' | ||
182 | && typeof entry.proposal === 'string' | ||
183 | && isStringOrUndefined(entry.label) | ||
184 | && isStringOrUndefined(entry.description) | ||
185 | && isStringOrUndefined(entry.documentation) | ||
186 | && isNumberOrUndefined(entry.escapePosition) | ||
187 | && isArrayOfType(entry.textReplacements, isReplaceRegion) | ||
188 | && isArrayOfType(entry.editPositions, isTextRegion) | ||
189 | && typeof entry.kind === 'string'; | ||
190 | } | ||
191 | |||
192 | export interface IContentAssistResult extends IDocumentStateResult { | ||
193 | entries: IContentAssistEntry[]; | ||
194 | } | ||
195 | |||
196 | export function isContentAssistResult(result: unknown): result is IContentAssistResult { | ||
197 | const contentAssistResult = result as IContentAssistResult; | ||
198 | return isDocumentStateResult(result) | ||
199 | && isArrayOfType(contentAssistResult.entries, isContentAssistEntry); | ||
74 | } | 200 | } |