aboutsummaryrefslogtreecommitdiffstats
path: root/subprojects/frontend/src/xtext
diff options
context:
space:
mode:
authorLibravatar Kristóf Marussy <kristof@marussy.com>2021-12-13 02:07:04 +0100
committerLibravatar Kristóf Marussy <kristof@marussy.com>2021-12-14 02:14:23 +0100
commita96c52b21e7e590bbdd70b80896780a446fa2e8b (patch)
tree663619baa254577bb2f5342192e80bca692ad91d /subprojects/frontend/src/xtext
parentbuild: move modules into subproject directory (diff)
downloadrefinery-a96c52b21e7e590bbdd70b80896780a446fa2e8b.tar.gz
refinery-a96c52b21e7e590bbdd70b80896780a446fa2e8b.tar.zst
refinery-a96c52b21e7e590bbdd70b80896780a446fa2e8b.zip
build: separate module for frontend
This allows us to simplify the webpack configuration and the gradle build scripts.
Diffstat (limited to 'subprojects/frontend/src/xtext')
-rw-r--r--subprojects/frontend/src/xtext/ContentAssistService.ts219
-rw-r--r--subprojects/frontend/src/xtext/HighlightingService.ts37
-rw-r--r--subprojects/frontend/src/xtext/OccurrencesService.ts127
-rw-r--r--subprojects/frontend/src/xtext/UpdateService.ts363
-rw-r--r--subprojects/frontend/src/xtext/ValidationService.ts39
-rw-r--r--subprojects/frontend/src/xtext/XtextClient.ts86
-rw-r--r--subprojects/frontend/src/xtext/XtextWebSocketClient.ts362
-rw-r--r--subprojects/frontend/src/xtext/xtextMessages.ts40
-rw-r--r--subprojects/frontend/src/xtext/xtextServiceResults.ts112
9 files changed, 1385 insertions, 0 deletions
diff --git a/subprojects/frontend/src/xtext/ContentAssistService.ts b/subprojects/frontend/src/xtext/ContentAssistService.ts
new file mode 100644
index 00000000..8b872e06
--- /dev/null
+++ b/subprojects/frontend/src/xtext/ContentAssistService.ts
@@ -0,0 +1,219 @@
1import type {
2 Completion,
3 CompletionContext,
4 CompletionResult,
5} from '@codemirror/autocomplete';
6import { syntaxTree } from '@codemirror/language';
7import type { Transaction } from '@codemirror/state';
8import escapeStringRegexp from 'escape-string-regexp';
9
10import { implicitCompletion } from '../language/props';
11import type { UpdateService } from './UpdateService';
12import { getLogger } from '../utils/logger';
13import type { ContentAssistEntry } from './xtextServiceResults';
14
15const PROPOSALS_LIMIT = 1000;
16
17const IDENTIFIER_REGEXP_STR = '[a-zA-Z0-9_]*';
18
19const HIGH_PRIORITY_KEYWORDS = ['<->', '~>'];
20
21const log = getLogger('xtext.ContentAssistService');
22
23interface IFoundToken {
24 from: number;
25
26 to: number;
27
28 implicitCompletion: boolean;
29
30 text: string;
31}
32
33function findToken({ pos, state }: CompletionContext): IFoundToken | null {
34 const token = syntaxTree(state).resolveInner(pos, -1);
35 if (token === null) {
36 return null;
37 }
38 if (token.firstChild !== null) {
39 // We only autocomplete terminal nodes. If the current node is nonterminal,
40 // returning `null` makes us autocomplete with the empty prefix instead.
41 return null;
42 }
43 return {
44 from: token.from,
45 to: token.to,
46 implicitCompletion: token.type.prop(implicitCompletion) || false,
47 text: state.sliceDoc(token.from, token.to),
48 };
49}
50
51function shouldCompleteImplicitly(token: IFoundToken | null, context: CompletionContext): boolean {
52 return token !== null
53 && token.implicitCompletion
54 && context.pos - token.from >= 2;
55}
56
57function computeSpan(prefix: string, entryCount: number): RegExp {
58 const escapedPrefix = escapeStringRegexp(prefix);
59 if (entryCount < PROPOSALS_LIMIT) {
60 // Proposals with the current prefix fit the proposals limit.
61 // We can filter client side as long as the current prefix is preserved.
62 return new RegExp(`^${escapedPrefix}${IDENTIFIER_REGEXP_STR}$`);
63 }
64 // The current prefix overflows the proposals limits,
65 // so we have to fetch the completions again on the next keypress.
66 // Hopefully, it'll return a shorter list and we'll be able to filter client side.
67 return new RegExp(`^${escapedPrefix}$`);
68}
69
70function createCompletion(entry: ContentAssistEntry): Completion {
71 let boost: number;
72 switch (entry.kind) {
73 case 'KEYWORD':
74 // Some hard-to-type operators should be on top.
75 boost = HIGH_PRIORITY_KEYWORDS.includes(entry.proposal) ? 10 : -99;
76 break;
77 case 'TEXT':
78 case 'SNIPPET':
79 boost = -90;
80 break;
81 default: {
82 // Penalize qualified names (vs available unqualified names).
83 const extraSegments = entry.proposal.match(/::/g)?.length || 0;
84 boost = Math.max(-5 * extraSegments, -50);
85 }
86 break;
87 }
88 return {
89 label: entry.proposal,
90 detail: entry.description,
91 info: entry.documentation,
92 type: entry.kind?.toLowerCase(),
93 boost,
94 };
95}
96
97export class ContentAssistService {
98 private readonly updateService: UpdateService;
99
100 private lastCompletion: CompletionResult | null = null;
101
102 constructor(updateService: UpdateService) {
103 this.updateService = updateService;
104 }
105
106 onTransaction(transaction: Transaction): void {
107 if (this.shouldInvalidateCachedCompletion(transaction)) {
108 this.lastCompletion = null;
109 }
110 }
111
112 async contentAssist(context: CompletionContext): Promise<CompletionResult> {
113 const tokenBefore = findToken(context);
114 if (!context.explicit && !shouldCompleteImplicitly(tokenBefore, context)) {
115 return {
116 from: context.pos,
117 options: [],
118 };
119 }
120 let range: { from: number, to: number };
121 let prefix = '';
122 if (tokenBefore === null) {
123 range = {
124 from: context.pos,
125 to: context.pos,
126 };
127 prefix = '';
128 } else {
129 range = {
130 from: tokenBefore.from,
131 to: tokenBefore.to,
132 };
133 const prefixLength = context.pos - tokenBefore.from;
134 if (prefixLength > 0) {
135 prefix = tokenBefore.text.substring(0, context.pos - tokenBefore.from);
136 }
137 }
138 if (!context.explicit && this.shouldReturnCachedCompletion(tokenBefore)) {
139 log.trace('Returning cached completion result');
140 // Postcondition of `shouldReturnCachedCompletion`: `lastCompletion !== null`
141 return {
142 ...this.lastCompletion as CompletionResult,
143 ...range,
144 };
145 }
146 this.lastCompletion = null;
147 const entries = await this.updateService.fetchContentAssist({
148 resource: this.updateService.resourceName,
149 serviceType: 'assist',
150 caretOffset: context.pos,
151 proposalsLimit: PROPOSALS_LIMIT,
152 }, context);
153 if (context.aborted) {
154 return {
155 ...range,
156 options: [],
157 };
158 }
159 const options: Completion[] = [];
160 entries.forEach((entry) => {
161 if (prefix === entry.prefix) {
162 // Xtext will generate completions that do not complete the current token,
163 // e.g., `(` after trying to complete an indetifier,
164 // but we ignore those, since CodeMirror won't filter for them anyways.
165 options.push(createCompletion(entry));
166 }
167 });
168 log.debug('Fetched', options.length, 'completions from server');
169 this.lastCompletion = {
170 ...range,
171 options,
172 span: computeSpan(prefix, entries.length),
173 };
174 return this.lastCompletion;
175 }
176
177 private shouldReturnCachedCompletion(
178 token: { from: number, to: number, text: string } | null,
179 ): boolean {
180 if (token === null || this.lastCompletion === null) {
181 return false;
182 }
183 const { from, to, text } = token;
184 const { from: lastFrom, to: lastTo, span } = this.lastCompletion;
185 if (!lastTo) {
186 return true;
187 }
188 const [transformedFrom, transformedTo] = this.mapRangeInclusive(lastFrom, lastTo);
189 return from >= transformedFrom
190 && to <= transformedTo
191 && typeof span !== 'undefined'
192 && span.exec(text) !== null;
193 }
194
195 private shouldInvalidateCachedCompletion(transaction: Transaction): boolean {
196 if (!transaction.docChanged || this.lastCompletion === null) {
197 return false;
198 }
199 const { from: lastFrom, to: lastTo } = this.lastCompletion;
200 if (!lastTo) {
201 return true;
202 }
203 const [transformedFrom, transformedTo] = this.mapRangeInclusive(lastFrom, lastTo);
204 let invalidate = false;
205 transaction.changes.iterChangedRanges((fromA, toA) => {
206 if (fromA < transformedFrom || toA > transformedTo) {
207 invalidate = true;
208 }
209 });
210 return invalidate;
211 }
212
213 private mapRangeInclusive(lastFrom: number, lastTo: number): [number, number] {
214 const changes = this.updateService.computeChangesSinceLastUpdate();
215 const transformedFrom = changes.mapPos(lastFrom);
216 const transformedTo = changes.mapPos(lastTo, 1);
217 return [transformedFrom, transformedTo];
218 }
219}
diff --git a/subprojects/frontend/src/xtext/HighlightingService.ts b/subprojects/frontend/src/xtext/HighlightingService.ts
new file mode 100644
index 00000000..dfbb4a19
--- /dev/null
+++ b/subprojects/frontend/src/xtext/HighlightingService.ts
@@ -0,0 +1,37 @@
1import type { EditorStore } from '../editor/EditorStore';
2import type { IHighlightRange } from '../editor/semanticHighlighting';
3import type { UpdateService } from './UpdateService';
4import { highlightingResult } from './xtextServiceResults';
5
6export class HighlightingService {
7 private readonly store: EditorStore;
8
9 private readonly updateService: UpdateService;
10
11 constructor(store: EditorStore, updateService: UpdateService) {
12 this.store = store;
13 this.updateService = updateService;
14 }
15
16 onPush(push: unknown): void {
17 const { regions } = highlightingResult.parse(push);
18 const allChanges = this.updateService.computeChangesSinceLastUpdate();
19 const ranges: IHighlightRange[] = [];
20 regions.forEach(({ offset, length, styleClasses }) => {
21 if (styleClasses.length === 0) {
22 return;
23 }
24 const from = allChanges.mapPos(offset);
25 const to = allChanges.mapPos(offset + length);
26 if (to <= from) {
27 return;
28 }
29 ranges.push({
30 from,
31 to,
32 classes: styleClasses,
33 });
34 });
35 this.store.updateSemanticHighlighting(ranges);
36 }
37}
diff --git a/subprojects/frontend/src/xtext/OccurrencesService.ts b/subprojects/frontend/src/xtext/OccurrencesService.ts
new file mode 100644
index 00000000..bc865537
--- /dev/null
+++ b/subprojects/frontend/src/xtext/OccurrencesService.ts
@@ -0,0 +1,127 @@
1import { Transaction } from '@codemirror/state';
2
3import type { EditorStore } from '../editor/EditorStore';
4import type { IOccurrence } from '../editor/findOccurrences';
5import type { UpdateService } from './UpdateService';
6import { getLogger } from '../utils/logger';
7import { Timer } from '../utils/Timer';
8import { XtextWebSocketClient } from './XtextWebSocketClient';
9import {
10 isConflictResult,
11 occurrencesResult,
12 TextRegion,
13} from './xtextServiceResults';
14
15const FIND_OCCURRENCES_TIMEOUT_MS = 1000;
16
17// Must clear occurrences asynchronously from `onTransaction`,
18// because we must not emit a conflicting transaction when handling the pending transaction.
19const CLEAR_OCCURRENCES_TIMEOUT_MS = 10;
20
21const log = getLogger('xtext.OccurrencesService');
22
23function transformOccurrences(regions: TextRegion[]): IOccurrence[] {
24 const occurrences: IOccurrence[] = [];
25 regions.forEach(({ offset, length }) => {
26 if (length > 0) {
27 occurrences.push({
28 from: offset,
29 to: offset + length,
30 });
31 }
32 });
33 return occurrences;
34}
35
36export class OccurrencesService {
37 private readonly store: EditorStore;
38
39 private readonly webSocketClient: XtextWebSocketClient;
40
41 private readonly updateService: UpdateService;
42
43 private hasOccurrences = false;
44
45 private readonly findOccurrencesTimer = new Timer(() => {
46 this.handleFindOccurrences();
47 }, FIND_OCCURRENCES_TIMEOUT_MS);
48
49 private readonly clearOccurrencesTimer = new Timer(() => {
50 this.clearOccurrences();
51 }, CLEAR_OCCURRENCES_TIMEOUT_MS);
52
53 constructor(
54 store: EditorStore,
55 webSocketClient: XtextWebSocketClient,
56 updateService: UpdateService,
57 ) {
58 this.store = store;
59 this.webSocketClient = webSocketClient;
60 this.updateService = updateService;
61 }
62
63 onTransaction(transaction: Transaction): void {
64 if (transaction.docChanged) {
65 this.clearOccurrencesTimer.schedule();
66 this.findOccurrencesTimer.reschedule();
67 }
68 if (transaction.isUserEvent('select')) {
69 this.findOccurrencesTimer.reschedule();
70 }
71 }
72
73 private handleFindOccurrences() {
74 this.clearOccurrencesTimer.cancel();
75 this.updateOccurrences().catch((error) => {
76 log.error('Unexpected error while updating occurrences', error);
77 this.clearOccurrences();
78 });
79 }
80
81 private async updateOccurrences() {
82 await this.updateService.update();
83 const result = await this.webSocketClient.send({
84 resource: this.updateService.resourceName,
85 serviceType: 'occurrences',
86 expectedStateId: this.updateService.xtextStateId,
87 caretOffset: this.store.state.selection.main.head,
88 });
89 const allChanges = this.updateService.computeChangesSinceLastUpdate();
90 if (!allChanges.empty || isConflictResult(result, 'canceled')) {
91 // Stale occurrences result, the user already made some changes.
92 // We can safely ignore the occurrences and schedule a new find occurrences call.
93 this.clearOccurrences();
94 this.findOccurrencesTimer.schedule();
95 return;
96 }
97 const parsedOccurrencesResult = occurrencesResult.safeParse(result);
98 if (!parsedOccurrencesResult.success) {
99 log.error(
100 'Unexpected occurences result',
101 result,
102 'not an OccurrencesResult: ',
103 parsedOccurrencesResult.error,
104 );
105 this.clearOccurrences();
106 return;
107 }
108 const { stateId, writeRegions, readRegions } = parsedOccurrencesResult.data;
109 if (stateId !== this.updateService.xtextStateId) {
110 log.error('Unexpected state id, expected:', this.updateService.xtextStateId, 'got:', stateId);
111 this.clearOccurrences();
112 return;
113 }
114 const write = transformOccurrences(writeRegions);
115 const read = transformOccurrences(readRegions);
116 this.hasOccurrences = write.length > 0 || read.length > 0;
117 log.debug('Found', write.length, 'write and', read.length, 'read occurrences');
118 this.store.updateOccurrences(write, read);
119 }
120
121 private clearOccurrences() {
122 if (this.hasOccurrences) {
123 this.store.updateOccurrences([], []);
124 this.hasOccurrences = false;
125 }
126 }
127}
diff --git a/subprojects/frontend/src/xtext/UpdateService.ts b/subprojects/frontend/src/xtext/UpdateService.ts
new file mode 100644
index 00000000..e78944a9
--- /dev/null
+++ b/subprojects/frontend/src/xtext/UpdateService.ts
@@ -0,0 +1,363 @@
1import {
2 ChangeDesc,
3 ChangeSet,
4 ChangeSpec,
5 StateEffect,
6 Transaction,
7} from '@codemirror/state';
8import { nanoid } from 'nanoid';
9
10import type { EditorStore } from '../editor/EditorStore';
11import type { XtextWebSocketClient } from './XtextWebSocketClient';
12import { ConditionVariable } from '../utils/ConditionVariable';
13import { getLogger } from '../utils/logger';
14import { Timer } from '../utils/Timer';
15import {
16 ContentAssistEntry,
17 contentAssistResult,
18 documentStateResult,
19 formattingResult,
20 isConflictResult,
21} from './xtextServiceResults';
22
23const UPDATE_TIMEOUT_MS = 500;
24
25const WAIT_FOR_UPDATE_TIMEOUT_MS = 1000;
26
27const log = getLogger('xtext.UpdateService');
28
29const setDirtyChanges = StateEffect.define<ChangeSet>();
30
31export interface IAbortSignal {
32 aborted: boolean;
33}
34
35export class UpdateService {
36 resourceName: string;
37
38 xtextStateId: string | null = null;
39
40 private readonly store: EditorStore;
41
42 /**
43 * The changes being synchronized to the server if a full or delta text update is running,
44 * `null` otherwise.
45 */
46 private pendingUpdate: ChangeSet | null = null;
47
48 /**
49 * Local changes not yet sychronized to the server and not part of the running update, if any.
50 */
51 private dirtyChanges: ChangeSet;
52
53 private readonly webSocketClient: XtextWebSocketClient;
54
55 private readonly updatedCondition = new ConditionVariable(
56 () => this.pendingUpdate === null && this.xtextStateId !== null,
57 WAIT_FOR_UPDATE_TIMEOUT_MS,
58 );
59
60 private readonly idleUpdateTimer = new Timer(() => {
61 this.handleIdleUpdate();
62 }, UPDATE_TIMEOUT_MS);
63
64 constructor(store: EditorStore, webSocketClient: XtextWebSocketClient) {
65 this.resourceName = `${nanoid(7)}.problem`;
66 this.store = store;
67 this.dirtyChanges = this.newEmptyChangeSet();
68 this.webSocketClient = webSocketClient;
69 }
70
71 onReconnect(): void {
72 this.xtextStateId = null;
73 this.updateFullText().catch((error) => {
74 log.error('Unexpected error during initial update', error);
75 });
76 }
77
78 onTransaction(transaction: Transaction): void {
79 const setDirtyChangesEffect = transaction.effects.find(
80 (effect) => effect.is(setDirtyChanges),
81 ) as StateEffect<ChangeSet> | undefined;
82 if (setDirtyChangesEffect) {
83 const { value } = setDirtyChangesEffect;
84 if (this.pendingUpdate !== null) {
85 this.pendingUpdate = ChangeSet.empty(value.length);
86 }
87 this.dirtyChanges = value;
88 return;
89 }
90 if (transaction.docChanged) {
91 this.dirtyChanges = this.dirtyChanges.compose(transaction.changes);
92 this.idleUpdateTimer.reschedule();
93 }
94 }
95
96 /**
97 * Computes the summary of any changes happened since the last complete update.
98 *
99 * The result reflects any changes that happened since the `xtextStateId`
100 * version was uploaded to the server.
101 *
102 * @return the summary of changes since the last update
103 */
104 computeChangesSinceLastUpdate(): ChangeDesc {
105 return this.pendingUpdate?.composeDesc(this.dirtyChanges.desc) || this.dirtyChanges.desc;
106 }
107
108 private handleIdleUpdate() {
109 if (!this.webSocketClient.isOpen || this.dirtyChanges.empty) {
110 return;
111 }
112 if (this.pendingUpdate === null) {
113 this.update().catch((error) => {
114 log.error('Unexpected error during scheduled update', error);
115 });
116 }
117 this.idleUpdateTimer.reschedule();
118 }
119
120 private newEmptyChangeSet() {
121 return ChangeSet.of([], this.store.state.doc.length);
122 }
123
124 async updateFullText(): Promise<void> {
125 await this.withUpdate(() => this.doUpdateFullText());
126 }
127
128 private async doUpdateFullText(): Promise<[string, void]> {
129 const result = await this.webSocketClient.send({
130 resource: this.resourceName,
131 serviceType: 'update',
132 fullText: this.store.state.doc.sliceString(0),
133 });
134 const { stateId } = documentStateResult.parse(result);
135 return [stateId, undefined];
136 }
137
138 /**
139 * Makes sure that the document state on the server reflects recent
140 * local changes.
141 *
142 * Performs either an update with delta text or a full text update if needed.
143 * If there are not local dirty changes, the promise resolves immediately.
144 *
145 * @return a promise resolving when the update is completed
146 */
147 async update(): Promise<void> {
148 await this.prepareForDeltaUpdate();
149 const delta = this.computeDelta();
150 if (delta === null) {
151 return;
152 }
153 log.trace('Editor delta', delta);
154 await this.withUpdate(async () => {
155 const result = await this.webSocketClient.send({
156 resource: this.resourceName,
157 serviceType: 'update',
158 requiredStateId: this.xtextStateId,
159 ...delta,
160 });
161 const parsedDocumentStateResult = documentStateResult.safeParse(result);
162 if (parsedDocumentStateResult.success) {
163 return [parsedDocumentStateResult.data.stateId, undefined];
164 }
165 if (isConflictResult(result, 'invalidStateId')) {
166 return this.doFallbackToUpdateFullText();
167 }
168 throw parsedDocumentStateResult.error;
169 });
170 }
171
172 private doFallbackToUpdateFullText() {
173 if (this.pendingUpdate === null) {
174 throw new Error('Only a pending update can be extended');
175 }
176 log.warn('Delta update failed, performing full text update');
177 this.xtextStateId = null;
178 this.pendingUpdate = this.pendingUpdate.compose(this.dirtyChanges);
179 this.dirtyChanges = this.newEmptyChangeSet();
180 return this.doUpdateFullText();
181 }
182
183 async fetchContentAssist(
184 params: Record<string, unknown>,
185 signal: IAbortSignal,
186 ): Promise<ContentAssistEntry[]> {
187 await this.prepareForDeltaUpdate();
188 if (signal.aborted) {
189 return [];
190 }
191 const delta = this.computeDelta();
192 if (delta !== null) {
193 log.trace('Editor delta', delta);
194 const entries = await this.withUpdate(async () => {
195 const result = await this.webSocketClient.send({
196 ...params,
197 requiredStateId: this.xtextStateId,
198 ...delta,
199 });
200 const parsedContentAssistResult = contentAssistResult.safeParse(result);
201 if (parsedContentAssistResult.success) {
202 const { stateId, entries: resultEntries } = parsedContentAssistResult.data;
203 return [stateId, resultEntries];
204 }
205 if (isConflictResult(result, 'invalidStateId')) {
206 log.warn('Server state invalid during content assist');
207 const [newStateId] = await this.doFallbackToUpdateFullText();
208 // We must finish this state update transaction to prepare for any push events
209 // before querying for content assist, so we just return `null` and will query
210 // the content assist service later.
211 return [newStateId, null];
212 }
213 throw parsedContentAssistResult.error;
214 });
215 if (entries !== null) {
216 return entries;
217 }
218 if (signal.aborted) {
219 return [];
220 }
221 }
222 // Poscondition of `prepareForDeltaUpdate`: `xtextStateId !== null`
223 return this.doFetchContentAssist(params, this.xtextStateId as string);
224 }
225
226 private async doFetchContentAssist(params: Record<string, unknown>, expectedStateId: string) {
227 const result = await this.webSocketClient.send({
228 ...params,
229 requiredStateId: expectedStateId,
230 });
231 const { stateId, entries } = contentAssistResult.parse(result);
232 if (stateId !== expectedStateId) {
233 throw new Error(`Unexpected state id, expected: ${expectedStateId} got: ${stateId}`);
234 }
235 return entries;
236 }
237
238 async formatText(): Promise<void> {
239 await this.update();
240 let { from, to } = this.store.state.selection.main;
241 if (to <= from) {
242 from = 0;
243 to = this.store.state.doc.length;
244 }
245 log.debug('Formatting from', from, 'to', to);
246 await this.withUpdate(async () => {
247 const result = await this.webSocketClient.send({
248 resource: this.resourceName,
249 serviceType: 'format',
250 selectionStart: from,
251 selectionEnd: to,
252 });
253 const { stateId, formattedText } = formattingResult.parse(result);
254 this.applyBeforeDirtyChanges({
255 from,
256 to,
257 insert: formattedText,
258 });
259 return [stateId, null];
260 });
261 }
262
263 private computeDelta() {
264 if (this.dirtyChanges.empty) {
265 return null;
266 }
267 let minFromA = Number.MAX_SAFE_INTEGER;
268 let maxToA = 0;
269 let minFromB = Number.MAX_SAFE_INTEGER;
270 let maxToB = 0;
271 this.dirtyChanges.iterChangedRanges((fromA, toA, fromB, toB) => {
272 minFromA = Math.min(minFromA, fromA);
273 maxToA = Math.max(maxToA, toA);
274 minFromB = Math.min(minFromB, fromB);
275 maxToB = Math.max(maxToB, toB);
276 });
277 return {
278 deltaOffset: minFromA,
279 deltaReplaceLength: maxToA - minFromA,
280 deltaText: this.store.state.doc.sliceString(minFromB, maxToB),
281 };
282 }
283
284 private applyBeforeDirtyChanges(changeSpec: ChangeSpec) {
285 const pendingChanges = this.pendingUpdate?.compose(this.dirtyChanges) || this.dirtyChanges;
286 const revertChanges = pendingChanges.invert(this.store.state.doc);
287 const applyBefore = ChangeSet.of(changeSpec, revertChanges.newLength);
288 const redoChanges = pendingChanges.map(applyBefore.desc);
289 const changeSet = revertChanges.compose(applyBefore).compose(redoChanges);
290 this.store.dispatch({
291 changes: changeSet,
292 effects: [
293 setDirtyChanges.of(redoChanges),
294 ],
295 });
296 }
297
298 /**
299 * Executes an asynchronous callback that updates the state on the server.
300 *
301 * Ensures that updates happen sequentially and manages `pendingUpdate`
302 * and `dirtyChanges` to reflect changes being synchronized to the server
303 * and not yet synchronized to the server, respectively.
304 *
305 * Optionally, `callback` may return a second value that is retured by this function.
306 *
307 * Once the remote procedure call to update the server state finishes
308 * and returns the new `stateId`, `callback` must return _immediately_
309 * to ensure that the local `stateId` is updated likewise to be able to handle
310 * push messages referring to the new `stateId` from the server.
311 * If additional work is needed to compute the second value in some cases,
312 * use `T | null` instead of `T` as a return type and signal the need for additional
313 * computations by returning `null`. Thus additional computations can be performed
314 * outside of the critical section.
315 *
316 * @param callback the asynchronous callback that updates the server state
317 * @return a promise resolving to the second value returned by `callback`
318 */
319 private async withUpdate<T>(callback: () => Promise<[string, T]>): Promise<T> {
320 if (this.pendingUpdate !== null) {
321 throw new Error('Another update is pending, will not perform update');
322 }
323 this.pendingUpdate = this.dirtyChanges;
324 this.dirtyChanges = this.newEmptyChangeSet();
325 let newStateId: string | null = null;
326 try {
327 let result: T;
328 [newStateId, result] = await callback();
329 this.xtextStateId = newStateId;
330 this.pendingUpdate = null;
331 this.updatedCondition.notifyAll();
332 return result;
333 } catch (e) {
334 log.error('Error while update', e);
335 if (this.pendingUpdate === null) {
336 log.error('pendingUpdate was cleared during update');
337 } else {
338 this.dirtyChanges = this.pendingUpdate.compose(this.dirtyChanges);
339 }
340 this.pendingUpdate = null;
341 this.webSocketClient.forceReconnectOnError();
342 this.updatedCondition.rejectAll(e);
343 throw e;
344 }
345 }
346
347 /**
348 * Ensures that there is some state available on the server (`xtextStateId`)
349 * and that there is not pending update.
350 *
351 * After this function resolves, a delta text update is possible.
352 *
353 * @return a promise resolving when there is a valid state id but no pending update
354 */
355 private async prepareForDeltaUpdate() {
356 // If no update is pending, but the full text hasn't been uploaded to the server yet,
357 // we must start a full text upload.
358 if (this.pendingUpdate === null && this.xtextStateId === null) {
359 await this.updateFullText();
360 }
361 await this.updatedCondition.waitFor();
362 }
363}
diff --git a/subprojects/frontend/src/xtext/ValidationService.ts b/subprojects/frontend/src/xtext/ValidationService.ts
new file mode 100644
index 00000000..ff7d3700
--- /dev/null
+++ b/subprojects/frontend/src/xtext/ValidationService.ts
@@ -0,0 +1,39 @@
1import type { Diagnostic } from '@codemirror/lint';
2
3import type { EditorStore } from '../editor/EditorStore';
4import type { UpdateService } from './UpdateService';
5import { validationResult } from './xtextServiceResults';
6
7export class ValidationService {
8 private readonly store: EditorStore;
9
10 private readonly updateService: UpdateService;
11
12 constructor(store: EditorStore, updateService: UpdateService) {
13 this.store = store;
14 this.updateService = updateService;
15 }
16
17 onPush(push: unknown): void {
18 const { issues } = validationResult.parse(push);
19 const allChanges = this.updateService.computeChangesSinceLastUpdate();
20 const diagnostics: Diagnostic[] = [];
21 issues.forEach(({
22 offset,
23 length,
24 severity,
25 description,
26 }) => {
27 if (severity === 'ignore') {
28 return;
29 }
30 diagnostics.push({
31 from: allChanges.mapPos(offset),
32 to: allChanges.mapPos(offset + length),
33 severity,
34 message: description,
35 });
36 });
37 this.store.updateDiagnostics(diagnostics);
38 }
39}
diff --git a/subprojects/frontend/src/xtext/XtextClient.ts b/subprojects/frontend/src/xtext/XtextClient.ts
new file mode 100644
index 00000000..0898e725
--- /dev/null
+++ b/subprojects/frontend/src/xtext/XtextClient.ts
@@ -0,0 +1,86 @@
1import type {
2 CompletionContext,
3 CompletionResult,
4} from '@codemirror/autocomplete';
5import type { Transaction } from '@codemirror/state';
6
7import type { EditorStore } from '../editor/EditorStore';
8import { ContentAssistService } from './ContentAssistService';
9import { HighlightingService } from './HighlightingService';
10import { OccurrencesService } from './OccurrencesService';
11import { UpdateService } from './UpdateService';
12import { getLogger } from '../utils/logger';
13import { ValidationService } from './ValidationService';
14import { XtextWebSocketClient } from './XtextWebSocketClient';
15import { XtextWebPushService } from './xtextMessages';
16
17const log = getLogger('xtext.XtextClient');
18
19export class XtextClient {
20 private readonly webSocketClient: XtextWebSocketClient;
21
22 private readonly updateService: UpdateService;
23
24 private readonly contentAssistService: ContentAssistService;
25
26 private readonly highlightingService: HighlightingService;
27
28 private readonly validationService: ValidationService;
29
30 private readonly occurrencesService: OccurrencesService;
31
32 constructor(store: EditorStore) {
33 this.webSocketClient = new XtextWebSocketClient(
34 () => this.updateService.onReconnect(),
35 (resource, stateId, service, push) => this.onPush(resource, stateId, service, push),
36 );
37 this.updateService = new UpdateService(store, this.webSocketClient);
38 this.contentAssistService = new ContentAssistService(this.updateService);
39 this.highlightingService = new HighlightingService(store, this.updateService);
40 this.validationService = new ValidationService(store, this.updateService);
41 this.occurrencesService = new OccurrencesService(
42 store,
43 this.webSocketClient,
44 this.updateService,
45 );
46 }
47
48 onTransaction(transaction: Transaction): void {
49 // `ContentAssistService.prototype.onTransaction` needs the dirty change desc
50 // _before_ the current edit, so we call it before `updateService`.
51 this.contentAssistService.onTransaction(transaction);
52 this.updateService.onTransaction(transaction);
53 this.occurrencesService.onTransaction(transaction);
54 }
55
56 private onPush(resource: string, stateId: string, service: XtextWebPushService, push: unknown) {
57 const { resourceName, xtextStateId } = this.updateService;
58 if (resource !== resourceName) {
59 log.error('Unknown resource name: expected:', resourceName, 'got:', resource);
60 return;
61 }
62 if (stateId !== xtextStateId) {
63 log.error('Unexpected xtext state id: expected:', xtextStateId, 'got:', stateId);
64 // The current push message might be stale (referring to a previous state),
65 // so this is not neccessarily an error and there is no need to force-reconnect.
66 return;
67 }
68 switch (service) {
69 case 'highlight':
70 this.highlightingService.onPush(push);
71 return;
72 case 'validate':
73 this.validationService.onPush(push);
74 }
75 }
76
77 contentAssist(context: CompletionContext): Promise<CompletionResult> {
78 return this.contentAssistService.contentAssist(context);
79 }
80
81 formatText(): void {
82 this.updateService.formatText().catch((e) => {
83 log.error('Error while formatting text', e);
84 });
85 }
86}
diff --git a/subprojects/frontend/src/xtext/XtextWebSocketClient.ts b/subprojects/frontend/src/xtext/XtextWebSocketClient.ts
new file mode 100644
index 00000000..2ce20a54
--- /dev/null
+++ b/subprojects/frontend/src/xtext/XtextWebSocketClient.ts
@@ -0,0 +1,362 @@
1import { nanoid } from 'nanoid';
2
3import { getLogger } from '../utils/logger';
4import { PendingTask } from '../utils/PendingTask';
5import { Timer } from '../utils/Timer';
6import {
7 xtextWebErrorResponse,
8 XtextWebRequest,
9 xtextWebOkResponse,
10 xtextWebPushMessage,
11 XtextWebPushService,
12} from './xtextMessages';
13import { pongResult } from './xtextServiceResults';
14
15const XTEXT_SUBPROTOCOL_V1 = 'tools.refinery.language.web.xtext.v1';
16
17const WEBSOCKET_CLOSE_OK = 1000;
18
19const RECONNECT_DELAY_MS = [200, 1000, 5000, 30_000];
20
21const MAX_RECONNECT_DELAY_MS = RECONNECT_DELAY_MS[RECONNECT_DELAY_MS.length - 1];
22
23const BACKGROUND_IDLE_TIMEOUT_MS = 5 * 60 * 1000;
24
25const PING_TIMEOUT_MS = 10 * 1000;
26
27const REQUEST_TIMEOUT_MS = 1000;
28
29const log = getLogger('xtext.XtextWebSocketClient');
30
31export type ReconnectHandler = () => void;
32
33export type PushHandler = (
34 resourceId: string,
35 stateId: string,
36 service: XtextWebPushService,
37 data: unknown,
38) => void;
39
40enum State {
41 Initial,
42 Opening,
43 TabVisible,
44 TabHiddenIdle,
45 TabHiddenWaiting,
46 Error,
47 TimedOut,
48}
49
50export class XtextWebSocketClient {
51 private nextMessageId = 0;
52
53 private connection!: WebSocket;
54
55 private readonly pendingRequests = new Map<string, PendingTask<unknown>>();
56
57 private readonly onReconnect: ReconnectHandler;
58
59 private readonly onPush: PushHandler;
60
61 private state = State.Initial;
62
63 private reconnectTryCount = 0;
64
65 private readonly idleTimer = new Timer(() => {
66 this.handleIdleTimeout();
67 }, BACKGROUND_IDLE_TIMEOUT_MS);
68
69 private readonly pingTimer = new Timer(() => {
70 this.sendPing();
71 }, PING_TIMEOUT_MS);
72
73 private readonly reconnectTimer = new Timer(() => {
74 this.handleReconnect();
75 });
76
77 constructor(onReconnect: ReconnectHandler, onPush: PushHandler) {
78 this.onReconnect = onReconnect;
79 this.onPush = onPush;
80 document.addEventListener('visibilitychange', () => {
81 this.handleVisibilityChange();
82 });
83 this.reconnect();
84 }
85
86 private get isLogicallyClosed(): boolean {
87 return this.state === State.Error || this.state === State.TimedOut;
88 }
89
90 get isOpen(): boolean {
91 return this.state === State.TabVisible
92 || this.state === State.TabHiddenIdle
93 || this.state === State.TabHiddenWaiting;
94 }
95
96 private reconnect() {
97 if (this.isOpen || this.state === State.Opening) {
98 log.error('Trying to reconnect from', this.state);
99 return;
100 }
101 this.state = State.Opening;
102 const webSocketServer = window.origin.replace(/^http/, 'ws');
103 const webSocketUrl = `${webSocketServer}/xtext-service`;
104 this.connection = new WebSocket(webSocketUrl, XTEXT_SUBPROTOCOL_V1);
105 this.connection.addEventListener('open', () => {
106 if (this.connection.protocol !== XTEXT_SUBPROTOCOL_V1) {
107 log.error('Unknown subprotocol', this.connection.protocol, 'selected by server');
108 this.forceReconnectOnError();
109 }
110 if (document.visibilityState === 'hidden') {
111 this.handleTabHidden();
112 } else {
113 this.handleTabVisibleConnected();
114 }
115 log.info('Connected to websocket');
116 this.nextMessageId = 0;
117 this.reconnectTryCount = 0;
118 this.pingTimer.schedule();
119 this.onReconnect();
120 });
121 this.connection.addEventListener('error', (event) => {
122 log.error('Unexpected websocket error', event);
123 this.forceReconnectOnError();
124 });
125 this.connection.addEventListener('message', (event) => {
126 this.handleMessage(event.data);
127 });
128 this.connection.addEventListener('close', (event) => {
129 if (this.isLogicallyClosed && event.code === WEBSOCKET_CLOSE_OK
130 && this.pendingRequests.size === 0) {
131 log.info('Websocket closed');
132 return;
133 }
134 log.error('Websocket closed unexpectedly', event.code, event.reason);
135 this.forceReconnectOnError();
136 });
137 }
138
139 private handleVisibilityChange() {
140 if (document.visibilityState === 'hidden') {
141 if (this.state === State.TabVisible) {
142 this.handleTabHidden();
143 }
144 return;
145 }
146 this.idleTimer.cancel();
147 if (this.state === State.TabHiddenIdle || this.state === State.TabHiddenWaiting) {
148 this.handleTabVisibleConnected();
149 return;
150 }
151 if (this.state === State.TimedOut) {
152 this.reconnect();
153 }
154 }
155
156 private handleTabHidden() {
157 log.debug('Tab hidden while websocket is connected');
158 this.state = State.TabHiddenIdle;
159 this.idleTimer.schedule();
160 }
161
162 private handleTabVisibleConnected() {
163 log.debug('Tab visible while websocket is connected');
164 this.state = State.TabVisible;
165 }
166
167 private handleIdleTimeout() {
168 log.trace('Waiting for pending tasks before disconnect');
169 if (this.state === State.TabHiddenIdle) {
170 this.state = State.TabHiddenWaiting;
171 this.handleWaitingForDisconnect();
172 }
173 }
174
175 private handleWaitingForDisconnect() {
176 if (this.state !== State.TabHiddenWaiting) {
177 return;
178 }
179 const pending = this.pendingRequests.size;
180 if (pending === 0) {
181 log.info('Closing idle websocket');
182 this.state = State.TimedOut;
183 this.closeConnection(1000, 'idle timeout');
184 return;
185 }
186 log.info('Waiting for', pending, 'pending requests before closing websocket');
187 }
188
189 private sendPing() {
190 if (!this.isOpen) {
191 return;
192 }
193 const ping = nanoid();
194 log.trace('Ping', ping);
195 this.send({ ping }).then((result) => {
196 const parsedPongResult = pongResult.safeParse(result);
197 if (parsedPongResult.success && parsedPongResult.data.pong === ping) {
198 log.trace('Pong', ping);
199 this.pingTimer.schedule();
200 } else {
201 log.error('Invalid pong:', parsedPongResult, 'expected:', ping);
202 this.forceReconnectOnError();
203 }
204 }).catch((error) => {
205 log.error('Error while waiting for ping', error);
206 this.forceReconnectOnError();
207 });
208 }
209
210 send(request: unknown): Promise<unknown> {
211 if (!this.isOpen) {
212 throw new Error('Not open');
213 }
214 const messageId = this.nextMessageId.toString(16);
215 if (messageId in this.pendingRequests) {
216 log.error('Message id wraparound still pending', messageId);
217 this.rejectRequest(messageId, new Error('Message id wraparound'));
218 }
219 if (this.nextMessageId >= Number.MAX_SAFE_INTEGER) {
220 this.nextMessageId = 0;
221 } else {
222 this.nextMessageId += 1;
223 }
224 const message = JSON.stringify({
225 id: messageId,
226 request,
227 } as XtextWebRequest);
228 log.trace('Sending message', message);
229 return new Promise((resolve, reject) => {
230 const task = new PendingTask(resolve, reject, REQUEST_TIMEOUT_MS, () => {
231 this.removePendingRequest(messageId);
232 });
233 this.pendingRequests.set(messageId, task);
234 this.connection.send(message);
235 });
236 }
237
238 private handleMessage(messageStr: unknown) {
239 if (typeof messageStr !== 'string') {
240 log.error('Unexpected binary message', messageStr);
241 this.forceReconnectOnError();
242 return;
243 }
244 log.trace('Incoming websocket message', messageStr);
245 let message: unknown;
246 try {
247 message = JSON.parse(messageStr);
248 } catch (error) {
249 log.error('Json parse error', error);
250 this.forceReconnectOnError();
251 return;
252 }
253 const okResponse = xtextWebOkResponse.safeParse(message);
254 if (okResponse.success) {
255 const { id, response } = okResponse.data;
256 this.resolveRequest(id, response);
257 return;
258 }
259 const errorResponse = xtextWebErrorResponse.safeParse(message);
260 if (errorResponse.success) {
261 const { id, error, message: errorMessage } = errorResponse.data;
262 this.rejectRequest(id, new Error(`${error} error: ${errorMessage}`));
263 if (error === 'server') {
264 log.error('Reconnecting due to server error: ', errorMessage);
265 this.forceReconnectOnError();
266 }
267 return;
268 }
269 const pushMessage = xtextWebPushMessage.safeParse(message);
270 if (pushMessage.success) {
271 const {
272 resource,
273 stateId,
274 service,
275 push,
276 } = pushMessage.data;
277 this.onPush(resource, stateId, service, push);
278 } else {
279 log.error(
280 'Unexpected websocket message:',
281 message,
282 'not ok response because:',
283 okResponse.error,
284 'not error response because:',
285 errorResponse.error,
286 'not push message because:',
287 pushMessage.error,
288 );
289 this.forceReconnectOnError();
290 }
291 }
292
293 private resolveRequest(messageId: string, value: unknown) {
294 const pendingRequest = this.pendingRequests.get(messageId);
295 if (pendingRequest) {
296 pendingRequest.resolve(value);
297 this.removePendingRequest(messageId);
298 return;
299 }
300 log.error('Trying to resolve unknown request', messageId, 'with', value);
301 }
302
303 private rejectRequest(messageId: string, reason?: unknown) {
304 const pendingRequest = this.pendingRequests.get(messageId);
305 if (pendingRequest) {
306 pendingRequest.reject(reason);
307 this.removePendingRequest(messageId);
308 return;
309 }
310 log.error('Trying to reject unknown request', messageId, 'with', reason);
311 }
312
313 private removePendingRequest(messageId: string) {
314 this.pendingRequests.delete(messageId);
315 this.handleWaitingForDisconnect();
316 }
317
318 forceReconnectOnError(): void {
319 if (this.isLogicallyClosed) {
320 return;
321 }
322 this.abortPendingRequests();
323 this.closeConnection(1000, 'reconnecting due to error');
324 log.error('Reconnecting after delay due to error');
325 this.handleErrorState();
326 }
327
328 private abortPendingRequests() {
329 this.pendingRequests.forEach((request) => {
330 request.reject(new Error('Websocket disconnect'));
331 });
332 this.pendingRequests.clear();
333 }
334
335 private closeConnection(code: number, reason: string) {
336 this.pingTimer.cancel();
337 const { readyState } = this.connection;
338 if (readyState !== WebSocket.CLOSING && readyState !== WebSocket.CLOSED) {
339 this.connection.close(code, reason);
340 }
341 }
342
343 private handleErrorState() {
344 this.state = State.Error;
345 this.reconnectTryCount += 1;
346 const delay = RECONNECT_DELAY_MS[this.reconnectTryCount - 1] || MAX_RECONNECT_DELAY_MS;
347 log.info('Reconnecting in', delay, 'ms');
348 this.reconnectTimer.schedule(delay);
349 }
350
351 private handleReconnect() {
352 if (this.state !== State.Error) {
353 log.error('Unexpected reconnect in', this.state);
354 return;
355 }
356 if (document.visibilityState === 'hidden') {
357 this.state = State.TimedOut;
358 } else {
359 this.reconnect();
360 }
361 }
362}
diff --git a/subprojects/frontend/src/xtext/xtextMessages.ts b/subprojects/frontend/src/xtext/xtextMessages.ts
new file mode 100644
index 00000000..c4305fcf
--- /dev/null
+++ b/subprojects/frontend/src/xtext/xtextMessages.ts
@@ -0,0 +1,40 @@
1import { z } from 'zod';
2
3export const xtextWebRequest = z.object({
4 id: z.string().nonempty(),
5 request: z.unknown(),
6});
7
8export type XtextWebRequest = z.infer<typeof xtextWebRequest>;
9
10export const xtextWebOkResponse = z.object({
11 id: z.string().nonempty(),
12 response: z.unknown(),
13});
14
15export type XtextWebOkResponse = z.infer<typeof xtextWebOkResponse>;
16
17export const xtextWebErrorKind = z.enum(['request', 'server']);
18
19export type XtextWebErrorKind = z.infer<typeof xtextWebErrorKind>;
20
21export const xtextWebErrorResponse = z.object({
22 id: z.string().nonempty(),
23 error: xtextWebErrorKind,
24 message: z.string(),
25});
26
27export type XtextWebErrorResponse = z.infer<typeof xtextWebErrorResponse>;
28
29export const xtextWebPushService = z.enum(['highlight', 'validate']);
30
31export type XtextWebPushService = z.infer<typeof xtextWebPushService>;
32
33export const xtextWebPushMessage = z.object({
34 resource: z.string().nonempty(),
35 stateId: z.string().nonempty(),
36 service: xtextWebPushService,
37 push: z.unknown(),
38});
39
40export type XtextWebPushMessage = z.infer<typeof xtextWebPushMessage>;
diff --git a/subprojects/frontend/src/xtext/xtextServiceResults.ts b/subprojects/frontend/src/xtext/xtextServiceResults.ts
new file mode 100644
index 00000000..f79b059c
--- /dev/null
+++ b/subprojects/frontend/src/xtext/xtextServiceResults.ts
@@ -0,0 +1,112 @@
1import { z } from 'zod';
2
3export const pongResult = z.object({
4 pong: z.string().nonempty(),
5});
6
7export type PongResult = z.infer<typeof pongResult>;
8
9export const documentStateResult = z.object({
10 stateId: z.string().nonempty(),
11});
12
13export type DocumentStateResult = z.infer<typeof documentStateResult>;
14
15export const conflict = z.enum(['invalidStateId', 'canceled']);
16
17export type Conflict = z.infer<typeof conflict>;
18
19export const serviceConflictResult = z.object({
20 conflict,
21});
22
23export type ServiceConflictResult = z.infer<typeof serviceConflictResult>;
24
25export function isConflictResult(result: unknown, conflictType: Conflict): boolean {
26 const parsedConflictResult = serviceConflictResult.safeParse(result);
27 return parsedConflictResult.success && parsedConflictResult.data.conflict === conflictType;
28}
29
30export const severity = z.enum(['error', 'warning', 'info', 'ignore']);
31
32export type Severity = z.infer<typeof severity>;
33
34export const issue = z.object({
35 description: z.string().nonempty(),
36 severity,
37 line: z.number().int(),
38 column: z.number().int().nonnegative(),
39 offset: z.number().int().nonnegative(),
40 length: z.number().int().nonnegative(),
41});
42
43export type Issue = z.infer<typeof issue>;
44
45export const validationResult = z.object({
46 issues: issue.array(),
47});
48
49export type ValidationResult = z.infer<typeof validationResult>;
50
51export const replaceRegion = z.object({
52 offset: z.number().int().nonnegative(),
53 length: z.number().int().nonnegative(),
54 text: z.string(),
55});
56
57export type ReplaceRegion = z.infer<typeof replaceRegion>;
58
59export const textRegion = z.object({
60 offset: z.number().int().nonnegative(),
61 length: z.number().int().nonnegative(),
62});
63
64export type TextRegion = z.infer<typeof textRegion>;
65
66export const contentAssistEntry = z.object({
67 prefix: z.string(),
68 proposal: z.string().nonempty(),
69 label: z.string().optional(),
70 description: z.string().nonempty().optional(),
71 documentation: z.string().nonempty().optional(),
72 escapePosition: z.number().int().nonnegative().optional(),
73 textReplacements: replaceRegion.array(),
74 editPositions: textRegion.array(),
75 kind: z.string().nonempty(),
76});
77
78export type ContentAssistEntry = z.infer<typeof contentAssistEntry>;
79
80export const contentAssistResult = documentStateResult.extend({
81 entries: contentAssistEntry.array(),
82});
83
84export type ContentAssistResult = z.infer<typeof contentAssistResult>;
85
86export const highlightingRegion = z.object({
87 offset: z.number().int().nonnegative(),
88 length: z.number().int().nonnegative(),
89 styleClasses: z.string().nonempty().array(),
90});
91
92export type HighlightingRegion = z.infer<typeof highlightingRegion>;
93
94export const highlightingResult = z.object({
95 regions: highlightingRegion.array(),
96});
97
98export type HighlightingResult = z.infer<typeof highlightingResult>;
99
100export const occurrencesResult = documentStateResult.extend({
101 writeRegions: textRegion.array(),
102 readRegions: textRegion.array(),
103});
104
105export type OccurrencesResult = z.infer<typeof occurrencesResult>;
106
107export const formattingResult = documentStateResult.extend({
108 formattedText: z.string(),
109 replaceRegion: textRegion,
110});
111
112export type FormattingResult = z.infer<typeof formattingResult>;