aboutsummaryrefslogtreecommitdiffstats
path: root/language-web/src/main/js
diff options
context:
space:
mode:
authorLibravatar Kristóf Marussy <kristof@marussy.com>2021-10-30 02:26:43 +0200
committerLibravatar Kristóf Marussy <kristof@marussy.com>2021-10-31 19:26:13 +0100
commitcc6cc0336091f838d27d66267004675ee96e1a40 (patch)
tree00e634c0b7519416392cc558ca9ca3e8d7a43f66 /language-web/src/main/js
parentchore(web): refactor PendingTask (diff)
downloadrefinery-cc6cc0336091f838d27d66267004675ee96e1a40.tar.gz
refinery-cc6cc0336091f838d27d66267004675ee96e1a40.tar.zst
refinery-cc6cc0336091f838d27d66267004675ee96e1a40.zip
feat(web): add xtext content assist
Diffstat (limited to 'language-web/src/main/js')
-rw-r--r--language-web/src/main/js/editor/EditorParent.ts12
-rw-r--r--language-web/src/main/js/editor/EditorStore.ts7
-rw-r--r--language-web/src/main/js/editor/XtextClient.ts307
-rw-r--r--language-web/src/main/js/editor/xtextMessages.ts8
-rw-r--r--language-web/src/main/js/editor/xtextServiceResults.ts138
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 @@
1import {
2 Completion,
3 CompletionContext,
4 CompletionResult,
5} from '@codemirror/autocomplete';
1import type { Diagnostic } from '@codemirror/lint'; 6import type { Diagnostic } from '@codemirror/lint';
2import { 7import {
3 ChangeDesc, 8 ChangeDesc,
@@ -10,21 +15,20 @@ import type { EditorStore } from './EditorStore';
10import { getLogger } from '../logging'; 15import { getLogger } from '../logging';
11import { Timer } from '../utils/Timer'; 16import { Timer } from '../utils/Timer';
12import { 17import {
18 IContentAssistEntry,
19 isContentAssistResult,
13 isDocumentStateResult, 20 isDocumentStateResult,
14 isServiceConflictResult, 21 isInvalidStateIdConflictResult,
15 isValidationResult, 22 isValidationResult,
16} from './xtextServiceResults'; 23} from './xtextServiceResults';
17import { XtextWebSocketClient } from './XtextWebSocketClient'; 24import { XtextWebSocketClient } from './XtextWebSocketClient';
25import { PendingTask } from '../utils/PendingTask';
18 26
19const UPDATE_TIMEOUT_MS = 300; 27const UPDATE_TIMEOUT_MS = 500;
20
21const log = getLogger('XtextClient');
22 28
23enum UpdateAction { 29const WAIT_FOR_UPDATE_TIMEOUT_MS = 1000;
24 ForceReconnect,
25 30
26 FullTextUpdate, 31const log = getLogger('XtextClient');
27}
28 32
29export class XtextClient { 33export 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
22export type XtextWebErrorKind = typeof VALID_XTEXT_WEB_ERROR_KINDS[number]; 22export type XtextWebErrorKind = typeof VALID_XTEXT_WEB_ERROR_KINDS[number];
23 23
24export function isXtextWebErrorKind(value: unknown): value is XtextWebErrorKind {
25 return typeof value === 'string'
26 && VALID_XTEXT_WEB_ERROR_KINDS.includes(value as XtextWebErrorKind);
27}
28
24export interface IXtextWebErrorResponse { 29export 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
23export type Conflict = typeof VALID_CONFLICTS[number]; 23export type Conflict = typeof VALID_CONFLICTS[number];
24 24
25export function isConflict(value: unknown): value is Conflict {
26 return typeof value === 'string' && VALID_CONFLICTS.includes(value as Conflict);
27}
28
25export interface IServiceConflictResult { 29export interface IServiceConflictResult {
26 conflict: Conflict; 30 conflict: Conflict;
27} 31}
28 32
29export function isServiceConflictResult(result: unknown): result is IServiceConflictResult { 33export 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
39export function isInvalidStateIdConflictResult(result: unknown): boolean {
40 return isServiceConflictResult(result) && result.conflict === 'invalidStateId';
33} 41}
34 42
35export const VALID_SEVERITIES = ['error', 'warning', 'info', 'ignore'] as const; 43export const VALID_SEVERITIES = ['error', 'warning', 'info', 'ignore'] as const;
36 44
37export type Severity = typeof VALID_SEVERITIES[number]; 45export type Severity = typeof VALID_SEVERITIES[number];
38 46
47export function isSeverity(value: unknown): value is Severity {
48 return typeof value === 'string' && VALID_SEVERITIES.includes(value as Severity);
49}
50
39export interface IIssue { 51export 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
80function 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
69export function isValidationResult(result: unknown): result is IValidationResult { 84export 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
90export interface IReplaceRegion {
91 offset: number;
92
93 length: number;
94
95 text: string;
96}
97
98export 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
106export interface ITextRegion {
107 offset: number;
108
109 length: number;
110}
111
112export 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
119export 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
141export type XtextContentAssistEntryKind = typeof VALID_XTEXT_CONTENT_ASSIST_ENTRY_KINDS[number];
142
143export 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
150export 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
170function isStringOrUndefined(value: unknown): value is string | undefined {
171 return typeof value === 'string' || typeof value === 'undefined';
172}
173
174function isNumberOrUndefined(value: unknown): value is number | undefined {
175 return typeof value === 'number' || typeof value === 'undefined';
176}
177
178export 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
192export interface IContentAssistResult extends IDocumentStateResult {
193 entries: IContentAssistEntry[];
194}
195
196export function isContentAssistResult(result: unknown): result is IContentAssistResult {
197 const contentAssistResult = result as IContentAssistResult;
198 return isDocumentStateResult(result)
199 && isArrayOfType(contentAssistResult.entries, isContentAssistEntry);
74} 200}