aboutsummaryrefslogtreecommitdiffstats
path: root/language-web/src/main/js/editor/XtextClient.ts
diff options
context:
space:
mode:
Diffstat (limited to 'language-web/src/main/js/editor/XtextClient.ts')
-rw-r--r--language-web/src/main/js/editor/XtextClient.ts243
1 files changed, 243 insertions, 0 deletions
diff --git a/language-web/src/main/js/editor/XtextClient.ts b/language-web/src/main/js/editor/XtextClient.ts
new file mode 100644
index 00000000..eeb67d72
--- /dev/null
+++ b/language-web/src/main/js/editor/XtextClient.ts
@@ -0,0 +1,243 @@
1import { Diagnostic, setDiagnostics } from '@codemirror/lint';
2import {
3 ChangeDesc,
4 ChangeSet,
5 EditorState,
6 Transaction,
7} from '@codemirror/state';
8import { nanoid } from 'nanoid';
9
10import type { EditorStore } from './EditorStore';
11import { getLogger } from '../logging';
12import {
13 isDocumentStateResult,
14 isServiceConflictResult,
15 isValidationResult,
16} from './xtextServiceResults';
17import { XtextWebSocketClient } from './XtextWebSocketClient';
18
19const UPDATE_TIMEOUT_MS = 300;
20
21const log = getLogger('XtextClient');
22
23enum UpdateAction {
24 ForceReconnect,
25
26 FullTextUpdate,
27}
28
29export class XtextClient {
30 resourceName: string;
31
32 webSocketClient: XtextWebSocketClient;
33
34 xtextStateId: string | null = null;
35
36 pendingUpdate: ChangeDesc | null;
37
38 dirtyChanges: ChangeDesc;
39
40 updateTimeout: NodeJS.Timeout | null = null;
41
42 store: EditorStore;
43
44 constructor(store: EditorStore) {
45 this.resourceName = `${nanoid(7)}.problem`;
46 this.pendingUpdate = null;
47 this.store = store;
48 this.dirtyChanges = this.newEmptyChangeDesc();
49 this.webSocketClient = new XtextWebSocketClient(
50 () => {
51 this.updateFullText().catch((error) => {
52 log.error('Unexpected error during initial update', error);
53 });
54 },
55 (resource, stateId, service, push) => {
56 this.onPush(resource, stateId, service, push).catch((error) => {
57 log.error('Unexected error during push message handling', error);
58 });
59 },
60 );
61 }
62
63 onTransaction(transaction: Transaction): void {
64 const { changes } = transaction;
65 if (!changes.empty) {
66 this.dirtyChanges = this.dirtyChanges.composeDesc(changes.desc);
67 this.scheduleUpdate();
68 }
69 }
70
71 private async onPush(resource: string, stateId: string, service: string, push: unknown) {
72 if (resource !== this.resourceName) {
73 log.error('Unknown resource name: expected:', this.resourceName, 'got:', resource);
74 return;
75 }
76 if (stateId !== this.xtextStateId) {
77 log.error('Unexpected xtext state id: expected:', this.xtextStateId, 'got:', resource);
78 await this.updateFullText();
79 }
80 switch (service) {
81 case 'validate':
82 this.onValidate(push);
83 return;
84 case 'highlight':
85 // TODO
86 return;
87 default:
88 log.error('Unknown push service:', service);
89 break;
90 }
91 }
92
93 private onValidate(push: unknown) {
94 if (!isValidationResult(push)) {
95 log.error('Invalid validation result', push);
96 return;
97 }
98 const allChanges = this.computeChangesSinceLastUpdate();
99 const diagnostics: Diagnostic[] = [];
100 push.issues.forEach((issue) => {
101 if (issue.severity === 'ignore') {
102 return;
103 }
104 diagnostics.push({
105 from: allChanges.mapPos(issue.offset),
106 to: allChanges.mapPos(issue.offset + issue.length),
107 severity: issue.severity,
108 message: issue.description,
109 });
110 });
111 this.store.dispatch(setDiagnostics(this.store.state, diagnostics));
112 }
113
114 private computeChangesSinceLastUpdate() {
115 if (this.pendingUpdate === null) {
116 return this.dirtyChanges;
117 }
118 return this.pendingUpdate.composeDesc(this.dirtyChanges);
119 }
120
121 private scheduleUpdate() {
122 if (this.updateTimeout !== null) {
123 clearTimeout(this.updateTimeout);
124 }
125 this.updateTimeout = setTimeout(() => {
126 this.updateTimeout = null;
127 if (!this.webSocketClient.isOpen || this.dirtyChanges.empty) {
128 return;
129 }
130 if (!this.pendingUpdate) {
131 this.updateDeltaText().catch((error) => {
132 log.error('Unexpected error during scheduled update', error);
133 });
134 }
135 this.scheduleUpdate();
136 }, UPDATE_TIMEOUT_MS);
137 }
138
139 private newEmptyChangeDesc() {
140 const changeSet = ChangeSet.of([], this.store.state.doc.length);
141 return changeSet.desc;
142 }
143
144 private async updateFullText() {
145 await this.withUpdate(async () => {
146 const result = await this.webSocketClient.send({
147 resource: this.resourceName,
148 serviceType: 'update',
149 fullText: this.store.state.doc.sliceString(0),
150 });
151 if (isDocumentStateResult(result)) {
152 return result.stateId;
153 }
154 if (isServiceConflictResult(result)) {
155 log.error('Full text update conflict:', result.conflict);
156 if (result.conflict === 'canceled') {
157 return UpdateAction.FullTextUpdate;
158 }
159 return UpdateAction.ForceReconnect;
160 }
161 log.error('Unexpected full text update result:', result);
162 return UpdateAction.ForceReconnect;
163 });
164 }
165
166 private async updateDeltaText() {
167 if (this.xtextStateId === null) {
168 await this.updateFullText();
169 return;
170 }
171 const delta = this.computeDelta();
172 log.debug('Editor delta', delta);
173 await this.withUpdate(async () => {
174 const result = await this.webSocketClient.send({
175 resource: this.resourceName,
176 serviceType: 'update',
177 requiredStateId: this.xtextStateId,
178 ...delta,
179 });
180 if (isDocumentStateResult(result)) {
181 return result.stateId;
182 }
183 if (isServiceConflictResult(result)) {
184 log.error('Delta text update conflict:', result.conflict);
185 return UpdateAction.FullTextUpdate;
186 }
187 log.error('Unexpected delta text update result:', result);
188 return UpdateAction.ForceReconnect;
189 });
190 }
191
192 private computeDelta() {
193 if (this.dirtyChanges.empty) {
194 return {};
195 }
196 let minFromA = Number.MAX_SAFE_INTEGER;
197 let maxToA = 0;
198 let minFromB = Number.MAX_SAFE_INTEGER;
199 let maxToB = 0;
200 this.dirtyChanges.iterChangedRanges((fromA, toA, fromB, toB) => {
201 minFromA = Math.min(minFromA, fromA);
202 maxToA = Math.max(maxToA, toA);
203 minFromB = Math.min(minFromB, fromB);
204 maxToB = Math.max(maxToB, toB);
205 });
206 return {
207 deltaOffset: minFromA,
208 deltaReplaceLength: maxToA - minFromA,
209 deltaText: this.store.state.doc.sliceString(minFromB, maxToB),
210 };
211 }
212
213 private async withUpdate(callback: () => Promise<string | UpdateAction>) {
214 if (this.pendingUpdate !== null) {
215 log.error('Another update is pending, will not perform update');
216 return;
217 }
218 this.pendingUpdate = this.dirtyChanges;
219 this.dirtyChanges = this.newEmptyChangeDesc();
220 let newStateId: string | UpdateAction = UpdateAction.ForceReconnect;
221 try {
222 newStateId = await callback();
223 } catch (error) {
224 log.error('Error while updating state', error);
225 } finally {
226 if (typeof newStateId === 'string') {
227 this.xtextStateId = newStateId;
228 this.pendingUpdate = null;
229 } else {
230 this.dirtyChanges = this.pendingUpdate.composeDesc(this.dirtyChanges);
231 this.pendingUpdate = null;
232 switch (newStateId) {
233 case UpdateAction.ForceReconnect:
234 this.webSocketClient.forceReconnectDueToError();
235 break;
236 case UpdateAction.FullTextUpdate:
237 await this.updateFullText();
238 break;
239 }
240 }
241 }
242 }
243}