aboutsummaryrefslogtreecommitdiffstats
path: root/language-web/src/main/js/xtext
diff options
context:
space:
mode:
authorLibravatar Kristóf Marussy <kristof@marussy.com>2021-11-16 16:47:58 +0100
committerLibravatar Kristóf Marussy <kristof@marussy.com>2021-11-16 16:47:58 +0100
commit11cbae607b5a6f59f5a019b7eb525da689d2bb30 (patch)
treec40e4af57988becdcf7d363a2fb7107f668b8ba0 /language-web/src/main/js/xtext
parentchore(web): json validation with zod (diff)
downloadrefinery-11cbae607b5a6f59f5a019b7eb525da689d2bb30.tar.gz
refinery-11cbae607b5a6f59f5a019b7eb525da689d2bb30.tar.zst
refinery-11cbae607b5a6f59f5a019b7eb525da689d2bb30.zip
feat(web): xtext formatter client
Uses the xtext formatted on the server to format the document. Also adds the capability to take (delta) changes from the server and apply them before any pending local changes, then replay the changes. This means that the server-side formatter is effectively acting as a second user who is editing the document.
Diffstat (limited to 'language-web/src/main/js/xtext')
-rw-r--r--language-web/src/main/js/xtext/UpdateService.ts79
-rw-r--r--language-web/src/main/js/xtext/XtextClient.ts6
-rw-r--r--language-web/src/main/js/xtext/xtextServiceResults.ts7
3 files changed, 80 insertions, 12 deletions
diff --git a/language-web/src/main/js/xtext/UpdateService.ts b/language-web/src/main/js/xtext/UpdateService.ts
index fa48c5ab..e78944a9 100644
--- a/language-web/src/main/js/xtext/UpdateService.ts
+++ b/language-web/src/main/js/xtext/UpdateService.ts
@@ -1,6 +1,8 @@
1import { 1import {
2 ChangeDesc, 2 ChangeDesc,
3 ChangeSet, 3 ChangeSet,
4 ChangeSpec,
5 StateEffect,
4 Transaction, 6 Transaction,
5} from '@codemirror/state'; 7} from '@codemirror/state';
6import { nanoid } from 'nanoid'; 8import { nanoid } from 'nanoid';
@@ -14,6 +16,7 @@ import {
14 ContentAssistEntry, 16 ContentAssistEntry,
15 contentAssistResult, 17 contentAssistResult,
16 documentStateResult, 18 documentStateResult,
19 formattingResult,
17 isConflictResult, 20 isConflictResult,
18} from './xtextServiceResults'; 21} from './xtextServiceResults';
19 22
@@ -23,6 +26,8 @@ const WAIT_FOR_UPDATE_TIMEOUT_MS = 1000;
23 26
24const log = getLogger('xtext.UpdateService'); 27const log = getLogger('xtext.UpdateService');
25 28
29const setDirtyChanges = StateEffect.define<ChangeSet>();
30
26export interface IAbortSignal { 31export interface IAbortSignal {
27 aborted: boolean; 32 aborted: boolean;
28} 33}
@@ -38,12 +43,12 @@ export class UpdateService {
38 * The changes being synchronized to the server if a full or delta text update is running, 43 * The changes being synchronized to the server if a full or delta text update is running,
39 * `null` otherwise. 44 * `null` otherwise.
40 */ 45 */
41 private pendingUpdate: ChangeDesc | null = null; 46 private pendingUpdate: ChangeSet | null = null;
42 47
43 /** 48 /**
44 * Local changes not yet sychronized to the server and not part of the running update, if any. 49 * Local changes not yet sychronized to the server and not part of the running update, if any.
45 */ 50 */
46 private dirtyChanges: ChangeDesc; 51 private dirtyChanges: ChangeSet;
47 52
48 private readonly webSocketClient: XtextWebSocketClient; 53 private readonly webSocketClient: XtextWebSocketClient;
49 54
@@ -59,7 +64,7 @@ export class UpdateService {
59 constructor(store: EditorStore, webSocketClient: XtextWebSocketClient) { 64 constructor(store: EditorStore, webSocketClient: XtextWebSocketClient) {
60 this.resourceName = `${nanoid(7)}.problem`; 65 this.resourceName = `${nanoid(7)}.problem`;
61 this.store = store; 66 this.store = store;
62 this.dirtyChanges = this.newEmptyChangeDesc(); 67 this.dirtyChanges = this.newEmptyChangeSet();
63 this.webSocketClient = webSocketClient; 68 this.webSocketClient = webSocketClient;
64 } 69 }
65 70
@@ -71,8 +76,19 @@ export class UpdateService {
71 } 76 }
72 77
73 onTransaction(transaction: Transaction): void { 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 }
74 if (transaction.docChanged) { 90 if (transaction.docChanged) {
75 this.dirtyChanges = this.dirtyChanges.composeDesc(transaction.changes.desc); 91 this.dirtyChanges = this.dirtyChanges.compose(transaction.changes);
76 this.idleUpdateTimer.reschedule(); 92 this.idleUpdateTimer.reschedule();
77 } 93 }
78 } 94 }
@@ -86,7 +102,7 @@ export class UpdateService {
86 * @return the summary of changes since the last update 102 * @return the summary of changes since the last update
87 */ 103 */
88 computeChangesSinceLastUpdate(): ChangeDesc { 104 computeChangesSinceLastUpdate(): ChangeDesc {
89 return this.pendingUpdate?.composeDesc(this.dirtyChanges) || this.dirtyChanges; 105 return this.pendingUpdate?.composeDesc(this.dirtyChanges.desc) || this.dirtyChanges.desc;
90 } 106 }
91 107
92 private handleIdleUpdate() { 108 private handleIdleUpdate() {
@@ -101,9 +117,8 @@ export class UpdateService {
101 this.idleUpdateTimer.reschedule(); 117 this.idleUpdateTimer.reschedule();
102 } 118 }
103 119
104 private newEmptyChangeDesc() { 120 private newEmptyChangeSet() {
105 const changeSet = ChangeSet.of([], this.store.state.doc.length); 121 return ChangeSet.of([], this.store.state.doc.length);
106 return changeSet.desc;
107 } 122 }
108 123
109 async updateFullText(): Promise<void> { 124 async updateFullText(): Promise<void> {
@@ -160,8 +175,8 @@ export class UpdateService {
160 } 175 }
161 log.warn('Delta update failed, performing full text update'); 176 log.warn('Delta update failed, performing full text update');
162 this.xtextStateId = null; 177 this.xtextStateId = null;
163 this.pendingUpdate = this.pendingUpdate.composeDesc(this.dirtyChanges); 178 this.pendingUpdate = this.pendingUpdate.compose(this.dirtyChanges);
164 this.dirtyChanges = this.newEmptyChangeDesc(); 179 this.dirtyChanges = this.newEmptyChangeSet();
165 return this.doUpdateFullText(); 180 return this.doUpdateFullText();
166 } 181 }
167 182
@@ -188,6 +203,7 @@ export class UpdateService {
188 return [stateId, resultEntries]; 203 return [stateId, resultEntries];
189 } 204 }
190 if (isConflictResult(result, 'invalidStateId')) { 205 if (isConflictResult(result, 'invalidStateId')) {
206 log.warn('Server state invalid during content assist');
191 const [newStateId] = await this.doFallbackToUpdateFullText(); 207 const [newStateId] = await this.doFallbackToUpdateFullText();
192 // We must finish this state update transaction to prepare for any push events 208 // We must finish this state update transaction to prepare for any push events
193 // before querying for content assist, so we just return `null` and will query 209 // before querying for content assist, so we just return `null` and will query
@@ -219,6 +235,31 @@ export class UpdateService {
219 return entries; 235 return entries;
220 } 236 }
221 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
222 private computeDelta() { 263 private computeDelta() {
223 if (this.dirtyChanges.empty) { 264 if (this.dirtyChanges.empty) {
224 return null; 265 return null;
@@ -240,6 +281,20 @@ export class UpdateService {
240 }; 281 };
241 } 282 }
242 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
243 /** 298 /**
244 * Executes an asynchronous callback that updates the state on the server. 299 * Executes an asynchronous callback that updates the state on the server.
245 * 300 *
@@ -266,7 +321,7 @@ export class UpdateService {
266 throw new Error('Another update is pending, will not perform update'); 321 throw new Error('Another update is pending, will not perform update');
267 } 322 }
268 this.pendingUpdate = this.dirtyChanges; 323 this.pendingUpdate = this.dirtyChanges;
269 this.dirtyChanges = this.newEmptyChangeDesc(); 324 this.dirtyChanges = this.newEmptyChangeSet();
270 let newStateId: string | null = null; 325 let newStateId: string | null = null;
271 try { 326 try {
272 let result: T; 327 let result: T;
@@ -280,7 +335,7 @@ export class UpdateService {
280 if (this.pendingUpdate === null) { 335 if (this.pendingUpdate === null) {
281 log.error('pendingUpdate was cleared during update'); 336 log.error('pendingUpdate was cleared during update');
282 } else { 337 } else {
283 this.dirtyChanges = this.pendingUpdate.composeDesc(this.dirtyChanges); 338 this.dirtyChanges = this.pendingUpdate.compose(this.dirtyChanges);
284 } 339 }
285 this.pendingUpdate = null; 340 this.pendingUpdate = null;
286 this.webSocketClient.forceReconnectOnError(); 341 this.webSocketClient.forceReconnectOnError();
diff --git a/language-web/src/main/js/xtext/XtextClient.ts b/language-web/src/main/js/xtext/XtextClient.ts
index 3922b230..0898e725 100644
--- a/language-web/src/main/js/xtext/XtextClient.ts
+++ b/language-web/src/main/js/xtext/XtextClient.ts
@@ -77,4 +77,10 @@ export class XtextClient {
77 contentAssist(context: CompletionContext): Promise<CompletionResult> { 77 contentAssist(context: CompletionContext): Promise<CompletionResult> {
78 return this.contentAssistService.contentAssist(context); 78 return this.contentAssistService.contentAssist(context);
79 } 79 }
80
81 formatText(): void {
82 this.updateService.formatText().catch((e) => {
83 log.error('Error while formatting text', e);
84 });
85 }
80} 86}
diff --git a/language-web/src/main/js/xtext/xtextServiceResults.ts b/language-web/src/main/js/xtext/xtextServiceResults.ts
index b6867e2f..f79b059c 100644
--- a/language-web/src/main/js/xtext/xtextServiceResults.ts
+++ b/language-web/src/main/js/xtext/xtextServiceResults.ts
@@ -103,3 +103,10 @@ export const occurrencesResult = documentStateResult.extend({
103}); 103});
104 104
105export type OccurrencesResult = z.infer<typeof occurrencesResult>; 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>;